mindcode 0.2.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.
- mindcode/__init__.py +5 -0
- mindcode/__main__.py +4 -0
- mindcode/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/__pycache__/__main__.cpython-313.pyc +0 -0
- mindcode/__pycache__/_version.cpython-313.pyc +0 -0
- mindcode/__pycache__/config.cpython-313.pyc +0 -0
- mindcode/__pycache__/policy.cpython-313.pyc +0 -0
- mindcode/__pycache__/remote.cpython-313.pyc +0 -0
- mindcode/__pycache__/render.cpython-313.pyc +0 -0
- mindcode/__pycache__/runtime.cpython-313.pyc +0 -0
- mindcode/__pycache__/tasking.cpython-313.pyc +0 -0
- mindcode/__pycache__/terminal_core.cpython-313.pyc +0 -0
- mindcode/_version.py +11 -0
- mindcode/approval.py +105 -0
- mindcode/bench/__init__.py +19 -0
- mindcode/bench/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/fake_tools.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/jsonutil.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/paths.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/predictions.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/report.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/runner.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/schema.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/trace.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/workspace.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__init__.py +22 -0
- mindcode/bench/adapters/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/base.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/mini_bfcl.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/mini_gaia.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/mini_terminal.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/swe_bench.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/terminal_bench.cpython-313.pyc +0 -0
- mindcode/bench/adapters/base.py +45 -0
- mindcode/bench/adapters/mini_bfcl.py +18 -0
- mindcode/bench/adapters/mini_gaia.py +18 -0
- mindcode/bench/adapters/mini_terminal.py +18 -0
- mindcode/bench/adapters/swe_bench.py +86 -0
- mindcode/bench/adapters/terminal_bench.py +107 -0
- mindcode/bench/fake_tools.py +73 -0
- mindcode/bench/jsonutil.py +20 -0
- mindcode/bench/paths.py +10 -0
- mindcode/bench/predictions.py +65 -0
- mindcode/bench/report.py +48 -0
- mindcode/bench/runner.py +250 -0
- mindcode/bench/schema.py +78 -0
- mindcode/bench/scorers/__pycache__/base.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/composite.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/exact.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/json_call.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/swe.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/terminal.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/terminal_bench.cpython-313.pyc +0 -0
- mindcode/bench/scorers/base.py +22 -0
- mindcode/bench/scorers/composite.py +57 -0
- mindcode/bench/scorers/exact.py +30 -0
- mindcode/bench/scorers/json_call.py +44 -0
- mindcode/bench/scorers/swe.py +29 -0
- mindcode/bench/scorers/terminal.py +43 -0
- mindcode/bench/scorers/terminal_bench.py +25 -0
- mindcode/bench/trace.py +34 -0
- mindcode/bench/workspace.py +127 -0
- mindcode/cli/__init__.py +112 -0
- mindcode/cli/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/cli/__pycache__/_shared.cpython-313.pyc +0 -0
- mindcode/cli/_shared.py +43 -0
- mindcode/cli/commands/__init__.py +1 -0
- mindcode/cli/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/bench.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/chat.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/config_cmd.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/remote.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/shell.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/status.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/task.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/terminal.cpython-313.pyc +0 -0
- mindcode/cli/commands/bench.py +365 -0
- mindcode/cli/commands/chat.py +86 -0
- mindcode/cli/commands/config_cmd.py +197 -0
- mindcode/cli/commands/remote.py +146 -0
- mindcode/cli/commands/shell.py +108 -0
- mindcode/cli/commands/status.py +37 -0
- mindcode/cli/commands/task.py +417 -0
- mindcode/cli/commands/terminal.py +124 -0
- mindcode/cli/shell/__init__.py +5 -0
- mindcode/cli/shell/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/completion.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/menu.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/repl.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/slash.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/startup.cpython-313.pyc +0 -0
- mindcode/cli/shell/completion.py +72 -0
- mindcode/cli/shell/menu.py +88 -0
- mindcode/cli/shell/repl.py +597 -0
- mindcode/cli/shell/slash.py +480 -0
- mindcode/cli/shell/startup.py +55 -0
- mindcode/cli/shell/tui.py +897 -0
- mindcode/config.py +173 -0
- mindcode/mcp.py +205 -0
- mindcode/policy.py +173 -0
- mindcode/remote.py +216 -0
- mindcode/render.py +458 -0
- mindcode/runtime.py +541 -0
- mindcode/skills.py +154 -0
- mindcode/subagents.py +43 -0
- mindcode/tasking.py +281 -0
- mindcode/terminal/__init__.py +1 -0
- mindcode/terminal/__main__.py +17 -0
- mindcode/terminal/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/terminal/__pycache__/__main__.cpython-313.pyc +0 -0
- mindcode/terminal_core.py +264 -0
- mindcode-0.2.0.dist-info/METADATA +244 -0
- mindcode-0.2.0.dist-info/RECORD +116 -0
- mindcode-0.2.0.dist-info/WHEEL +5 -0
- mindcode-0.2.0.dist-info/entry_points.txt +2 -0
- mindcode-0.2.0.dist-info/top_level.txt +1 -0
mindcode/remote.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .config import CONFIG_DIR
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
REMOTES_FILE = CONFIG_DIR / "remotes.json"
|
|
17
|
+
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RemoteConfig:
|
|
22
|
+
name: str
|
|
23
|
+
host: str
|
|
24
|
+
workdir: str
|
|
25
|
+
port: int = 22
|
|
26
|
+
identity_file: str | None = None
|
|
27
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, name: str, data: dict[str, Any]) -> "RemoteConfig":
|
|
31
|
+
env = data.get("env", {})
|
|
32
|
+
if not isinstance(env, dict):
|
|
33
|
+
env = {}
|
|
34
|
+
return cls(
|
|
35
|
+
name=name,
|
|
36
|
+
host=str(data.get("host", "")),
|
|
37
|
+
port=int(data.get("port", 22) or 22),
|
|
38
|
+
workdir=str(data.get("workdir", "")),
|
|
39
|
+
identity_file=(
|
|
40
|
+
str(data["identity_file"]) if data.get("identity_file") else None
|
|
41
|
+
),
|
|
42
|
+
env={str(key): str(value) for key, value in env.items()},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def validate(self) -> None:
|
|
46
|
+
if not self.name:
|
|
47
|
+
raise ValueError("remote name is required")
|
|
48
|
+
if not self.host:
|
|
49
|
+
raise ValueError("remote host is required")
|
|
50
|
+
if self.host.startswith("-") or any(
|
|
51
|
+
character.isspace() for character in self.host
|
|
52
|
+
):
|
|
53
|
+
raise ValueError("remote host must not contain options or whitespace")
|
|
54
|
+
if not self.workdir:
|
|
55
|
+
raise ValueError("remote workdir is required")
|
|
56
|
+
if self.port <= 0:
|
|
57
|
+
raise ValueError("remote port must be positive")
|
|
58
|
+
for key in self.env:
|
|
59
|
+
if not _ENV_NAME_RE.match(key):
|
|
60
|
+
raise ValueError(f"invalid environment variable name: {key}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RemoteStore:
|
|
64
|
+
def __init__(self, path: Path | None = None):
|
|
65
|
+
self.path = path or REMOTES_FILE
|
|
66
|
+
|
|
67
|
+
def load_all(self) -> dict[str, RemoteConfig]:
|
|
68
|
+
if not self.path.exists():
|
|
69
|
+
return {}
|
|
70
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
71
|
+
if not isinstance(data, dict):
|
|
72
|
+
return {}
|
|
73
|
+
remotes: dict[str, RemoteConfig] = {}
|
|
74
|
+
for name, raw in data.items():
|
|
75
|
+
if isinstance(raw, dict):
|
|
76
|
+
remotes[str(name)] = RemoteConfig.from_dict(str(name), raw)
|
|
77
|
+
return remotes
|
|
78
|
+
|
|
79
|
+
def save_all(self, remotes: dict[str, RemoteConfig]) -> None:
|
|
80
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
data = {
|
|
82
|
+
name: _config_payload(config)
|
|
83
|
+
for name, config in sorted(remotes.items())
|
|
84
|
+
}
|
|
85
|
+
tmp = self.path.with_suffix(".tmp")
|
|
86
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
87
|
+
os.chmod(tmp, 0o600)
|
|
88
|
+
tmp.replace(self.path)
|
|
89
|
+
|
|
90
|
+
def get(self, name: str) -> RemoteConfig:
|
|
91
|
+
remotes = self.load_all()
|
|
92
|
+
try:
|
|
93
|
+
return remotes[name]
|
|
94
|
+
except KeyError as exc:
|
|
95
|
+
raise KeyError(f"unknown remote: {name}") from exc
|
|
96
|
+
|
|
97
|
+
def add(self, config: RemoteConfig) -> None:
|
|
98
|
+
config.validate()
|
|
99
|
+
remotes = self.load_all()
|
|
100
|
+
remotes[config.name] = config
|
|
101
|
+
self.save_all(remotes)
|
|
102
|
+
|
|
103
|
+
def remove(self, name: str) -> bool:
|
|
104
|
+
remotes = self.load_all()
|
|
105
|
+
if name not in remotes:
|
|
106
|
+
return False
|
|
107
|
+
del remotes[name]
|
|
108
|
+
self.save_all(remotes)
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class RemoteResult:
|
|
114
|
+
argv: list[str]
|
|
115
|
+
exit_code: int
|
|
116
|
+
stdout: str
|
|
117
|
+
stderr: str
|
|
118
|
+
duration_s: float
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SSHCommandBuilder:
|
|
122
|
+
def build_ssh_argv(self, config: RemoteConfig, remote_command: str) -> list[str]:
|
|
123
|
+
config.validate()
|
|
124
|
+
argv = [
|
|
125
|
+
"ssh",
|
|
126
|
+
"-p",
|
|
127
|
+
str(config.port),
|
|
128
|
+
"-o",
|
|
129
|
+
"StrictHostKeyChecking=accept-new",
|
|
130
|
+
]
|
|
131
|
+
if config.identity_file:
|
|
132
|
+
argv.extend(["-i", str(Path(config.identity_file).expanduser())])
|
|
133
|
+
argv.append(config.host)
|
|
134
|
+
argv.append(self.build_remote_shell_command(config, remote_command))
|
|
135
|
+
return argv
|
|
136
|
+
|
|
137
|
+
def build_remote_shell_command(
|
|
138
|
+
self,
|
|
139
|
+
config: RemoteConfig,
|
|
140
|
+
remote_command: str,
|
|
141
|
+
) -> str:
|
|
142
|
+
config.validate()
|
|
143
|
+
parts = [f"cd {shlex.quote(config.workdir)}"]
|
|
144
|
+
if config.env:
|
|
145
|
+
env_parts = [
|
|
146
|
+
f"{key}={shlex.quote(value)}" for key, value in sorted(config.env.items())
|
|
147
|
+
]
|
|
148
|
+
parts.append("env " + " ".join(env_parts) + f" {remote_command}")
|
|
149
|
+
else:
|
|
150
|
+
parts.append(remote_command)
|
|
151
|
+
return " && ".join(parts)
|
|
152
|
+
|
|
153
|
+
def build_tail_command(
|
|
154
|
+
self,
|
|
155
|
+
path: str,
|
|
156
|
+
*,
|
|
157
|
+
lines: int = 80,
|
|
158
|
+
follow: bool = False,
|
|
159
|
+
) -> str:
|
|
160
|
+
if lines <= 0:
|
|
161
|
+
raise ValueError("lines must be positive")
|
|
162
|
+
flags = ["tail", "-n", str(lines)]
|
|
163
|
+
if follow:
|
|
164
|
+
flags.append("-f")
|
|
165
|
+
flags.extend(["--", shlex.quote(path)])
|
|
166
|
+
return " ".join(flags)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class RemoteExecutor:
|
|
170
|
+
def __init__(self, builder: SSHCommandBuilder | None = None):
|
|
171
|
+
self.builder = builder or SSHCommandBuilder()
|
|
172
|
+
|
|
173
|
+
def execute(
|
|
174
|
+
self,
|
|
175
|
+
config: RemoteConfig,
|
|
176
|
+
command: str,
|
|
177
|
+
*,
|
|
178
|
+
timeout_s: float | None = None,
|
|
179
|
+
) -> RemoteResult:
|
|
180
|
+
argv = self.builder.build_ssh_argv(config, command)
|
|
181
|
+
started = time.monotonic()
|
|
182
|
+
try:
|
|
183
|
+
process = subprocess.run(
|
|
184
|
+
argv,
|
|
185
|
+
text=True,
|
|
186
|
+
capture_output=True,
|
|
187
|
+
timeout=timeout_s,
|
|
188
|
+
check=False,
|
|
189
|
+
)
|
|
190
|
+
except subprocess.TimeoutExpired as exc:
|
|
191
|
+
duration = time.monotonic() - started
|
|
192
|
+
return RemoteResult(
|
|
193
|
+
argv=argv,
|
|
194
|
+
exit_code=124,
|
|
195
|
+
stdout=exc.stdout or "",
|
|
196
|
+
stderr=exc.stderr or f"remote command timed out after {timeout_s}s",
|
|
197
|
+
duration_s=duration,
|
|
198
|
+
)
|
|
199
|
+
duration = time.monotonic() - started
|
|
200
|
+
return RemoteResult(
|
|
201
|
+
argv=argv,
|
|
202
|
+
exit_code=process.returncode,
|
|
203
|
+
stdout=process.stdout,
|
|
204
|
+
stderr=process.stderr,
|
|
205
|
+
duration_s=duration,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _config_payload(config: RemoteConfig) -> dict[str, Any]:
|
|
210
|
+
payload = asdict(config)
|
|
211
|
+
payload.pop("name", None)
|
|
212
|
+
return payload
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def mask_env(env: dict[str, str]) -> dict[str, str]:
|
|
216
|
+
return {key: "***" for key in env}
|
mindcode/render.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mindagent.core import AgentEvent, EventType
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RichRenderer:
|
|
15
|
+
"""EventHandler implementation that renders agent events with rich.
|
|
16
|
+
|
|
17
|
+
Tracks a `_streaming` flag so TEXT_DELTA and tool-call events can flush
|
|
18
|
+
each other's line breaks correctly.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, console: Console | None = None) -> None:
|
|
22
|
+
self.console = console or Console()
|
|
23
|
+
self._streaming = False
|
|
24
|
+
self._did_stream = False
|
|
25
|
+
self.last_diff: str | None = None
|
|
26
|
+
self.last_edit_path: str | None = None
|
|
27
|
+
self._transcript: Any | None = None
|
|
28
|
+
self.total_tokens: int | None = None
|
|
29
|
+
|
|
30
|
+
def attach_transcript(self, transcript: Any) -> None:
|
|
31
|
+
"""Send assistant Markdown into the shell TUI when one is active."""
|
|
32
|
+
self._transcript = transcript
|
|
33
|
+
|
|
34
|
+
def detach_transcript(self) -> None:
|
|
35
|
+
self._transcript = None
|
|
36
|
+
|
|
37
|
+
def reset(self) -> None:
|
|
38
|
+
self._flush_stream()
|
|
39
|
+
self._streaming = False
|
|
40
|
+
self._did_stream = False
|
|
41
|
+
|
|
42
|
+
async def __call__(self, event: AgentEvent) -> None:
|
|
43
|
+
handler = self._dispatch.get(event.event_type)
|
|
44
|
+
if handler is not None:
|
|
45
|
+
await handler(self, event)
|
|
46
|
+
|
|
47
|
+
async def _on_text_delta(self, event: AgentEvent) -> None:
|
|
48
|
+
if self._transcript is not None:
|
|
49
|
+
self._streaming = True
|
|
50
|
+
self._did_stream = True
|
|
51
|
+
self._transcript.append_delta(event.payload.get("delta", ""))
|
|
52
|
+
return
|
|
53
|
+
if not self._streaming:
|
|
54
|
+
self.console.print()
|
|
55
|
+
self.console.print("[bold cyan]assistant›[/bold cyan]", end=" ")
|
|
56
|
+
self._streaming = True
|
|
57
|
+
self._did_stream = True
|
|
58
|
+
delta = event.payload.get("delta", "")
|
|
59
|
+
self.console.out(delta, end="", highlight=False)
|
|
60
|
+
|
|
61
|
+
async def _on_action_started(self, event: AgentEvent) -> None:
|
|
62
|
+
self._flush_stream()
|
|
63
|
+
name = event.payload.get("action_name", "?")
|
|
64
|
+
args = event.payload.get("arguments", {}) or {}
|
|
65
|
+
if self._transcript is not None:
|
|
66
|
+
label, detail = _tui_tool_summary(name, args)
|
|
67
|
+
self._transcript.add_tool(label, detail)
|
|
68
|
+
return
|
|
69
|
+
risk = event.payload.get("risk", "")
|
|
70
|
+
risk_tag = f" [dim yellow][{risk}][/dim yellow]" if risk else ""
|
|
71
|
+
|
|
72
|
+
if name == "file_edit":
|
|
73
|
+
path = args.get("path", "?")
|
|
74
|
+
self.console.print(
|
|
75
|
+
f" [bold blue]›[/bold blue] file_edit"
|
|
76
|
+
f" [cyan]{path}[/cyan]{risk_tag}"
|
|
77
|
+
)
|
|
78
|
+
elif name == "file_write":
|
|
79
|
+
path = args.get("path", "?")
|
|
80
|
+
self.console.print(
|
|
81
|
+
f" [bold blue]›[/bold blue] file_write"
|
|
82
|
+
f" [cyan]{path}[/cyan]{risk_tag}"
|
|
83
|
+
)
|
|
84
|
+
elif name == "exec_command":
|
|
85
|
+
argv = args.get("argv") or args.get("command") or []
|
|
86
|
+
if isinstance(argv, str):
|
|
87
|
+
# 模型把 command 传成了字符串(应为数组):整体展示,标注类型异常,
|
|
88
|
+
# 不要逐字符迭代(否则渲染成 "[ ' f i n d ' ...")。
|
|
89
|
+
preview = argv
|
|
90
|
+
type_note = " [dim red](参数应为数组)[/dim red]"
|
|
91
|
+
else:
|
|
92
|
+
preview = " ".join(_quote(a) for a in argv)
|
|
93
|
+
type_note = ""
|
|
94
|
+
if len(preview) > 120:
|
|
95
|
+
preview = preview[:117] + "..."
|
|
96
|
+
self.console.print(
|
|
97
|
+
f" [bold blue]›[/bold blue] exec [green]{preview}[/green]"
|
|
98
|
+
f"{type_note}{risk_tag}"
|
|
99
|
+
)
|
|
100
|
+
elif name == "file_read":
|
|
101
|
+
path = args.get("path", "?")
|
|
102
|
+
self.console.print(
|
|
103
|
+
f" [bold blue]›[/bold blue] file_read [cyan]{path}[/cyan]"
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
short = json.dumps(args, ensure_ascii=False)
|
|
107
|
+
if len(short) > 100:
|
|
108
|
+
short = short[:97] + "..."
|
|
109
|
+
self.console.print(
|
|
110
|
+
f" [bold blue]›[/bold blue] [dim]{name}[/dim] {short}{risk_tag}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def _on_observation(self, event: AgentEvent) -> None:
|
|
114
|
+
name = event.payload.get("action_name", "?")
|
|
115
|
+
ok = event.payload.get("ok", False)
|
|
116
|
+
result = event.payload.get("result")
|
|
117
|
+
error = event.payload.get("error")
|
|
118
|
+
|
|
119
|
+
if self._transcript is not None:
|
|
120
|
+
self._render_tui_observation(name, ok, result, error)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if name in {"file_edit", "file_write"} and ok:
|
|
124
|
+
diff = result.get("mindcode_diff", "") if isinstance(result, dict) else ""
|
|
125
|
+
path = result.get("path", "") if isinstance(result, dict) else ""
|
|
126
|
+
if diff:
|
|
127
|
+
self.last_diff = diff
|
|
128
|
+
self.last_edit_path = path
|
|
129
|
+
self._render_diff_panel(diff, path)
|
|
130
|
+
else:
|
|
131
|
+
self.console.print(
|
|
132
|
+
f" [green]✓[/green] {name} [cyan]{path}[/cyan]"
|
|
133
|
+
" [dim](无变化)[/dim]"
|
|
134
|
+
)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
if name == "exec_command":
|
|
138
|
+
exit_code, timed_out, succeeded, reason = _command_status(
|
|
139
|
+
ok, result, error
|
|
140
|
+
)
|
|
141
|
+
if succeeded:
|
|
142
|
+
stdout = (
|
|
143
|
+
result.get("stdout", "") if isinstance(result, dict) else ""
|
|
144
|
+
)
|
|
145
|
+
preview = _first_line(stdout)
|
|
146
|
+
if preview:
|
|
147
|
+
self.console.print(
|
|
148
|
+
f" [green]✓[/green] exit=0 "
|
|
149
|
+
f"[dim]{preview}[/dim]"
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
self.console.print(" [green]✓[/green] exit=0")
|
|
153
|
+
else:
|
|
154
|
+
detail = _format_command_failure(
|
|
155
|
+
exit_code,
|
|
156
|
+
timed_out,
|
|
157
|
+
reason,
|
|
158
|
+
exit_separator="=",
|
|
159
|
+
)
|
|
160
|
+
self.console.print(f" [red]✗[/red] {detail}")
|
|
161
|
+
stderr = (
|
|
162
|
+
result.get("stderr", "") if isinstance(result, dict) else ""
|
|
163
|
+
)
|
|
164
|
+
if stderr:
|
|
165
|
+
self._render_text_panel(stderr, "stderr", border="red")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
status = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
169
|
+
if ok:
|
|
170
|
+
self.console.print(f" {status} [dim]{name}[/dim]")
|
|
171
|
+
else:
|
|
172
|
+
msg = error or "failed"
|
|
173
|
+
self.console.print(f" {status} [dim]{name}:[/dim] {msg}")
|
|
174
|
+
|
|
175
|
+
async def _on_decision(self, event: AgentEvent) -> None:
|
|
176
|
+
kind = event.payload.get("decision_kind", "")
|
|
177
|
+
summary = event.payload.get("reasoning_summary") or ""
|
|
178
|
+
self._record_usage(event.payload.get("usage"))
|
|
179
|
+
if not summary:
|
|
180
|
+
return
|
|
181
|
+
preview = summary if len(summary) <= 80 else summary[:77] + "..."
|
|
182
|
+
if self._transcript is not None:
|
|
183
|
+
self._transcript.add_thinking(preview)
|
|
184
|
+
return
|
|
185
|
+
self.console.print(
|
|
186
|
+
f" [dim italic][{kind}][/dim italic] [dim]{preview}[/dim]"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def _on_error(self, event: AgentEvent) -> None:
|
|
190
|
+
self._flush_stream()
|
|
191
|
+
error = event.payload.get("error", "unknown error")
|
|
192
|
+
info = event.payload.get("error_info") or {}
|
|
193
|
+
body = error
|
|
194
|
+
if isinstance(info, dict):
|
|
195
|
+
code = info.get("code")
|
|
196
|
+
if code:
|
|
197
|
+
body = f"[{code}] {error}"
|
|
198
|
+
self.console.print(
|
|
199
|
+
Panel(
|
|
200
|
+
Text(body, style="red"),
|
|
201
|
+
border_style="red",
|
|
202
|
+
title="error",
|
|
203
|
+
title_align="left",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def _on_completion_rejected(self, event: AgentEvent) -> None:
|
|
208
|
+
reasons = event.payload.get("reasons") or []
|
|
209
|
+
text = "\n".join(f"• {r}" for r in reasons)
|
|
210
|
+
self.console.print(
|
|
211
|
+
Panel(
|
|
212
|
+
Text(text or "完成声明被拒绝", style="yellow"),
|
|
213
|
+
border_style="yellow",
|
|
214
|
+
title="completion rejected",
|
|
215
|
+
title_align="left",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _flush_stream(self) -> None:
|
|
220
|
+
if self._transcript is not None:
|
|
221
|
+
if self._streaming:
|
|
222
|
+
self._transcript.finish_stream()
|
|
223
|
+
self._streaming = False
|
|
224
|
+
return
|
|
225
|
+
if self._streaming:
|
|
226
|
+
self.console.print()
|
|
227
|
+
self._streaming = False
|
|
228
|
+
|
|
229
|
+
def render_final_answer(self, content: str) -> None:
|
|
230
|
+
"""Render a non-streamed final answer in either shell presentation."""
|
|
231
|
+
if self._transcript is not None:
|
|
232
|
+
self._transcript.add_assistant(content)
|
|
233
|
+
return
|
|
234
|
+
self.console.print(f"\n[bold cyan]answer›[/bold cyan] {content}")
|
|
235
|
+
|
|
236
|
+
def _render_tui_observation(
|
|
237
|
+
self,
|
|
238
|
+
name: str,
|
|
239
|
+
ok: bool,
|
|
240
|
+
result: Any,
|
|
241
|
+
error: Any,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Update concise tool rows instead of exposing internal tool names."""
|
|
244
|
+
assert self._transcript is not None
|
|
245
|
+
if name in {"file_edit", "file_write"}:
|
|
246
|
+
diff = result.get("mindcode_diff", "") if isinstance(result, dict) else ""
|
|
247
|
+
path = result.get("path", "") if isinstance(result, dict) else ""
|
|
248
|
+
if ok and diff:
|
|
249
|
+
self.last_diff = diff
|
|
250
|
+
self.last_edit_path = path
|
|
251
|
+
self._transcript.finish_tool(True, f"updated {path}")
|
|
252
|
+
self._transcript.add_tool_detail(f"```diff\n{diff}\n```")
|
|
253
|
+
elif ok:
|
|
254
|
+
self._transcript.finish_tool(True, f"no changes {path}")
|
|
255
|
+
else:
|
|
256
|
+
self._transcript.finish_tool(False, str(error or "edit failed"))
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
if name == "exec_command":
|
|
260
|
+
exit_code, timed_out, succeeded, reason = _command_status(
|
|
261
|
+
ok, result, error
|
|
262
|
+
)
|
|
263
|
+
if succeeded:
|
|
264
|
+
stdout = result.get("stdout", "") if isinstance(result, dict) else ""
|
|
265
|
+
preview = _first_line(stdout)
|
|
266
|
+
detail = "exit 0"
|
|
267
|
+
if preview:
|
|
268
|
+
detail += f" · {preview}"
|
|
269
|
+
self._transcript.finish_tool(True, detail)
|
|
270
|
+
else:
|
|
271
|
+
self._transcript.finish_tool(
|
|
272
|
+
False,
|
|
273
|
+
_format_command_failure(
|
|
274
|
+
exit_code,
|
|
275
|
+
timed_out,
|
|
276
|
+
reason,
|
|
277
|
+
exit_separator=" ",
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
stderr = result.get("stderr", "") if isinstance(result, dict) else ""
|
|
281
|
+
if stderr:
|
|
282
|
+
self._transcript.add_tool_detail(f"```text\n{stderr}\n```")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
self._transcript.finish_tool(
|
|
286
|
+
bool(ok),
|
|
287
|
+
"complete" if ok else str(error or "failed"),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _record_usage(self, usage: Any) -> None:
|
|
291
|
+
if not isinstance(usage, dict):
|
|
292
|
+
return
|
|
293
|
+
total = usage.get("total_tokens")
|
|
294
|
+
if not isinstance(total, int) or total < 0:
|
|
295
|
+
return
|
|
296
|
+
self.total_tokens = (self.total_tokens or 0) + total
|
|
297
|
+
|
|
298
|
+
def _render_diff_panel(self, diff_text: str, path: str) -> None:
|
|
299
|
+
syntax = Syntax(
|
|
300
|
+
diff_text,
|
|
301
|
+
"diff",
|
|
302
|
+
theme="ansi_dark",
|
|
303
|
+
line_numbers=False,
|
|
304
|
+
word_wrap=True,
|
|
305
|
+
background_color="default",
|
|
306
|
+
)
|
|
307
|
+
self.console.print(
|
|
308
|
+
Panel(
|
|
309
|
+
syntax,
|
|
310
|
+
border_style="cyan",
|
|
311
|
+
title=f"diff: {path}",
|
|
312
|
+
title_align="left",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _render_text_panel(
|
|
317
|
+
self,
|
|
318
|
+
text: str,
|
|
319
|
+
title: str,
|
|
320
|
+
*,
|
|
321
|
+
border: str = "dim",
|
|
322
|
+
) -> None:
|
|
323
|
+
body = text if len(text) <= 2000 else text[:1997] + "..."
|
|
324
|
+
self.console.print(
|
|
325
|
+
Panel(
|
|
326
|
+
Text(body),
|
|
327
|
+
border_style=border,
|
|
328
|
+
title=title,
|
|
329
|
+
title_align="left",
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
_dispatch = {
|
|
334
|
+
EventType.TEXT_DELTA: _on_text_delta,
|
|
335
|
+
EventType.ACTION_STARTED: _on_action_started,
|
|
336
|
+
EventType.OBSERVATION_CREATED: _on_observation,
|
|
337
|
+
EventType.DECISION_CREATED: _on_decision,
|
|
338
|
+
EventType.ERROR_CREATED: _on_error,
|
|
339
|
+
EventType.COMPLETION_REJECTED: _on_completion_rejected,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _quote(arg: Any) -> str:
|
|
344
|
+
s = str(arg)
|
|
345
|
+
if any(c in s for c in {" ", "\t", "'", '"', "$"}):
|
|
346
|
+
return "'" + s.replace("'", "'\\''") + "'"
|
|
347
|
+
return s
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _first_line(text: str, *, limit: int = 100) -> str:
|
|
351
|
+
if not text:
|
|
352
|
+
return ""
|
|
353
|
+
line = text.splitlines()[0] if text.splitlines() else ""
|
|
354
|
+
return line if len(line) <= limit else line[: limit - 3] + "..."
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _command_status(
|
|
358
|
+
ok: bool,
|
|
359
|
+
result: Any,
|
|
360
|
+
error: Any,
|
|
361
|
+
) -> tuple[int | None, bool, bool, str]:
|
|
362
|
+
"""Return a user-facing status without inventing an exit code.
|
|
363
|
+
|
|
364
|
+
A missing or malformed ``exit_code`` means the command did not produce a
|
|
365
|
+
process result. It must not be rendered as ``exit None``; use the
|
|
366
|
+
observation error (when available) as the primary failure reason instead.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
payload = result if isinstance(result, dict) else {}
|
|
370
|
+
raw_exit_code = payload.get("exit_code")
|
|
371
|
+
exit_code = (
|
|
372
|
+
raw_exit_code
|
|
373
|
+
if isinstance(raw_exit_code, int) and not isinstance(raw_exit_code, bool)
|
|
374
|
+
else None
|
|
375
|
+
)
|
|
376
|
+
timed_out = bool(payload.get("timed_out", False))
|
|
377
|
+
|
|
378
|
+
if ok and exit_code == 0 and not timed_out:
|
|
379
|
+
return exit_code, timed_out, True, ""
|
|
380
|
+
|
|
381
|
+
reason = _error_text(error)
|
|
382
|
+
if exit_code is not None and exit_code != 0:
|
|
383
|
+
return exit_code, timed_out, False, reason
|
|
384
|
+
|
|
385
|
+
if timed_out:
|
|
386
|
+
return exit_code, timed_out, False, reason or "timed out"
|
|
387
|
+
|
|
388
|
+
if not ok:
|
|
389
|
+
return (
|
|
390
|
+
exit_code,
|
|
391
|
+
timed_out,
|
|
392
|
+
False,
|
|
393
|
+
reason or "command failed",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
return exit_code, timed_out, False, reason or "missing exit code"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _format_command_failure(
|
|
400
|
+
exit_code: int | None,
|
|
401
|
+
timed_out: bool,
|
|
402
|
+
reason: str,
|
|
403
|
+
*,
|
|
404
|
+
exit_separator: str,
|
|
405
|
+
) -> str:
|
|
406
|
+
"""Format a failed command for one of the renderer presentations."""
|
|
407
|
+
|
|
408
|
+
if exit_code is not None and exit_code != 0:
|
|
409
|
+
detail = f"exit{exit_separator}{exit_code}"
|
|
410
|
+
if timed_out:
|
|
411
|
+
detail += " timeout" if exit_separator == "=" else " · timed out"
|
|
412
|
+
if reason:
|
|
413
|
+
detail += f" · {reason}"
|
|
414
|
+
return detail
|
|
415
|
+
if timed_out:
|
|
416
|
+
detail = "failed · timed out"
|
|
417
|
+
if reason and reason != "timed out":
|
|
418
|
+
detail += f" · {reason}"
|
|
419
|
+
return detail
|
|
420
|
+
return f"failed · {reason}"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _error_text(error: Any) -> str:
|
|
424
|
+
if isinstance(error, str):
|
|
425
|
+
return error.strip()
|
|
426
|
+
if error is None:
|
|
427
|
+
return ""
|
|
428
|
+
return str(error).strip()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _tui_tool_summary(name: str, args: Any) -> tuple[str, str]:
|
|
432
|
+
"""Map runtime action names to compact, user-facing TUI labels."""
|
|
433
|
+
arguments = args if isinstance(args, dict) else {}
|
|
434
|
+
labels = {
|
|
435
|
+
"file_edit": "Edit",
|
|
436
|
+
"file_write": "Write",
|
|
437
|
+
"exec_command": "Command",
|
|
438
|
+
"file_read": "Read",
|
|
439
|
+
"memory_read": "Memory",
|
|
440
|
+
"delegate": "Delegate",
|
|
441
|
+
}
|
|
442
|
+
label = labels.get(name, name.replace("_", " ").title() or "Tool")
|
|
443
|
+
|
|
444
|
+
if name in {"file_edit", "file_write"}:
|
|
445
|
+
return label, str(arguments.get("path", ""))
|
|
446
|
+
if name == "exec_command":
|
|
447
|
+
command = arguments.get("argv") or arguments.get("command") or []
|
|
448
|
+
preview = command if isinstance(command, str) else " ".join(_quote(arg) for arg in command)
|
|
449
|
+
return label, _shorten(preview, 120)
|
|
450
|
+
if name == "file_read":
|
|
451
|
+
return label, str(arguments.get("path", ""))
|
|
452
|
+
|
|
453
|
+
detail = json.dumps(arguments, ensure_ascii=False)
|
|
454
|
+
return label, _shorten(detail, 100)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _shorten(text: str, limit: int) -> str:
|
|
458
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|