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/config.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CONFIG_DIR = Path.home() / ".cache" / "mindcode"
|
|
10
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
11
|
+
SESSIONS_DIR = CONFIG_DIR / "sessions"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CONFIG_TEMPLATE = """\
|
|
15
|
+
# mindcode configuration
|
|
16
|
+
# Located at: ~/.cache/mindcode/config.toml
|
|
17
|
+
#
|
|
18
|
+
# Edit this file to add or modify channels. API keys are stored locally
|
|
19
|
+
# and never sent anywhere except the channel endpoint.
|
|
20
|
+
#
|
|
21
|
+
# Inside the mindcode REPL you can also:
|
|
22
|
+
# /channels pick or add an API channel
|
|
23
|
+
# /models list and switch models of current channel
|
|
24
|
+
|
|
25
|
+
# Channel used when -p/--provider is not passed on the command line.
|
|
26
|
+
default_provider = "rotor"
|
|
27
|
+
|
|
28
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
29
|
+
# Rotor gateway — OpenAI-compatible gateway endpoint
|
|
30
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
31
|
+
[providers.rotor]
|
|
32
|
+
protocol = "openai"
|
|
33
|
+
api_key = ""
|
|
34
|
+
base_url = "http://127.0.0.1:8000/v1"
|
|
35
|
+
default_model = "glm-5.2"
|
|
36
|
+
models = [
|
|
37
|
+
"glm-5.2",
|
|
38
|
+
"glm-5.1",
|
|
39
|
+
"glm-4.7",
|
|
40
|
+
]
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ProviderConfig:
|
|
46
|
+
name: str
|
|
47
|
+
api_key: str
|
|
48
|
+
base_url: str
|
|
49
|
+
default_model: str
|
|
50
|
+
protocol: str = "openai"
|
|
51
|
+
models: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
def resolve_model(self, override: str | None) -> str:
|
|
54
|
+
if override:
|
|
55
|
+
return override
|
|
56
|
+
if self.default_model:
|
|
57
|
+
return self.default_model
|
|
58
|
+
if self.models:
|
|
59
|
+
return self.models[0]
|
|
60
|
+
raise ValueError(f"provider {self.name} 没有可用 model")
|
|
61
|
+
|
|
62
|
+
def validate(self) -> list[str]:
|
|
63
|
+
problems: list[str] = []
|
|
64
|
+
if self.protocol not in {"openai", "anthropic"}:
|
|
65
|
+
problems.append(
|
|
66
|
+
f"providers.{self.name}.protocol 不支持: {self.protocol}"
|
|
67
|
+
)
|
|
68
|
+
if not self.api_key:
|
|
69
|
+
problems.append(f"providers.{self.name}.api_key 未配置")
|
|
70
|
+
if not self.base_url:
|
|
71
|
+
problems.append(f"providers.{self.name}.base_url 未配置")
|
|
72
|
+
try:
|
|
73
|
+
self.resolve_model(None)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
problems.append(str(exc))
|
|
76
|
+
return problems
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class MindcodeConfig:
|
|
81
|
+
default_provider: str
|
|
82
|
+
providers: dict[str, ProviderConfig]
|
|
83
|
+
config_path: Path
|
|
84
|
+
|
|
85
|
+
def get_provider(self, name: str | None) -> ProviderConfig:
|
|
86
|
+
target = name or self.default_provider
|
|
87
|
+
if not target:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"config.toml 中未设置 default_provider,"
|
|
90
|
+
"请用 -p/--provider 显式指定"
|
|
91
|
+
)
|
|
92
|
+
if target not in self.providers:
|
|
93
|
+
available = ", ".join(sorted(self.providers)) or "(无)"
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"未知 provider: {target};可用: {available}"
|
|
96
|
+
)
|
|
97
|
+
return self.providers[target]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _render_toml(
|
|
101
|
+
default_provider: str,
|
|
102
|
+
providers: dict[str, ProviderConfig],
|
|
103
|
+
) -> str:
|
|
104
|
+
lines: list[str] = [
|
|
105
|
+
"# mindcode configuration",
|
|
106
|
+
f"# Located at: {CONFIG_FILE}",
|
|
107
|
+
"# Edit this file to add or modify providers.",
|
|
108
|
+
"",
|
|
109
|
+
f'default_provider = "{default_provider}"',
|
|
110
|
+
"",
|
|
111
|
+
]
|
|
112
|
+
for name, p in providers.items():
|
|
113
|
+
lines.append(f"[providers.{name}]")
|
|
114
|
+
lines.append(f'protocol = "{p.protocol}"')
|
|
115
|
+
lines.append(f'api_key = "{p.api_key}"')
|
|
116
|
+
lines.append(f'base_url = "{p.base_url}"')
|
|
117
|
+
lines.append(f'default_model = "{p.default_model}"')
|
|
118
|
+
if len(p.models) <= 1:
|
|
119
|
+
inner = ", ".join(f'"{m}"' for m in p.models)
|
|
120
|
+
lines.append(f"models = [{inner}]")
|
|
121
|
+
else:
|
|
122
|
+
lines.append("models = [")
|
|
123
|
+
for m in p.models:
|
|
124
|
+
lines.append(f' "{m}",')
|
|
125
|
+
lines.append("]")
|
|
126
|
+
lines.append("")
|
|
127
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_config(path: Path | None = None) -> MindcodeConfig:
|
|
131
|
+
"""Load config from path; create with template if missing."""
|
|
132
|
+
config_path = path or CONFIG_FILE
|
|
133
|
+
if not config_path.exists():
|
|
134
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
config_path.write_text(CONFIG_TEMPLATE, encoding="utf-8")
|
|
136
|
+
os.chmod(config_path, 0o600)
|
|
137
|
+
return _read_config(config_path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def save_config(config: MindcodeConfig) -> None:
|
|
141
|
+
"""Write config back to its config_path atomically."""
|
|
142
|
+
path = config.config_path
|
|
143
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
text = _render_toml(config.default_provider, config.providers)
|
|
145
|
+
tmp = path.with_suffix(".tmp")
|
|
146
|
+
tmp.write_text(text, encoding="utf-8")
|
|
147
|
+
os.chmod(tmp, 0o600)
|
|
148
|
+
tmp.replace(path)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _read_config(path: Path) -> MindcodeConfig:
|
|
152
|
+
with path.open("rb") as f:
|
|
153
|
+
data = tomllib.load(f)
|
|
154
|
+
providers_raw = data.get("providers", {})
|
|
155
|
+
if not isinstance(providers_raw, dict):
|
|
156
|
+
providers_raw = {}
|
|
157
|
+
providers: dict[str, ProviderConfig] = {}
|
|
158
|
+
for name, raw in providers_raw.items():
|
|
159
|
+
if not isinstance(raw, dict):
|
|
160
|
+
continue
|
|
161
|
+
providers[name] = ProviderConfig(
|
|
162
|
+
name=name,
|
|
163
|
+
protocol=str(raw.get("protocol", "openai")),
|
|
164
|
+
api_key=str(raw.get("api_key", "")),
|
|
165
|
+
base_url=str(raw.get("base_url", "")),
|
|
166
|
+
default_model=str(raw.get("default_model", "")),
|
|
167
|
+
models=list(raw.get("models", []) or []),
|
|
168
|
+
)
|
|
169
|
+
return MindcodeConfig(
|
|
170
|
+
default_provider=str(data.get("default_provider", "")),
|
|
171
|
+
providers=providers,
|
|
172
|
+
config_path=path,
|
|
173
|
+
)
|
mindcode/mcp.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Minimal MindCode-side MCP client and tool adapter.
|
|
2
|
+
|
|
3
|
+
This module deliberately defines a transport-neutral client protocol. A host
|
|
4
|
+
can provide an MCP implementation, while tests can use a tiny in-memory fake;
|
|
5
|
+
MindCode itself does not add a network dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
from collections.abc import Iterable, Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from mindagent.core import ActionRisk
|
|
16
|
+
from mindagent.tools.base import BaseTool, ToolContext, ToolDefinition
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class MCPToolSpec:
|
|
21
|
+
"""Transport-independent description returned by an MCP server."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
description: str = ""
|
|
25
|
+
input_schema: dict[str, Any] = field(
|
|
26
|
+
default_factory=lambda: {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {},
|
|
29
|
+
"additionalProperties": False,
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
risk: ActionRisk = ActionRisk.WRITE
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
name = self.name.strip()
|
|
36
|
+
if not name:
|
|
37
|
+
raise ValueError("MCP tool name 不能为空")
|
|
38
|
+
if not isinstance(self.input_schema, dict):
|
|
39
|
+
raise TypeError("MCP tool input_schema 必须是 object schema")
|
|
40
|
+
object.__setattr__(self, "name", name)
|
|
41
|
+
object.__setattr__(self, "description", self.description.strip())
|
|
42
|
+
object.__setattr__(self, "input_schema", dict(self.input_schema))
|
|
43
|
+
if not isinstance(self.risk, ActionRisk):
|
|
44
|
+
object.__setattr__(self, "risk", _coerce_risk(self.risk))
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> "MCPToolSpec":
|
|
48
|
+
annotations = value.get("annotations")
|
|
49
|
+
if not isinstance(annotations, Mapping):
|
|
50
|
+
annotations = {}
|
|
51
|
+
risk = value.get("risk", annotations.get("risk"))
|
|
52
|
+
if risk is None and annotations.get("readOnlyHint") is True:
|
|
53
|
+
risk = ActionRisk.READ_ONLY
|
|
54
|
+
if risk is None and annotations.get("destructiveHint") is True:
|
|
55
|
+
risk = ActionRisk.DANGEROUS
|
|
56
|
+
return cls(
|
|
57
|
+
name=str(value.get("name", "")),
|
|
58
|
+
description=str(value.get("description", "")),
|
|
59
|
+
input_schema=dict(
|
|
60
|
+
value.get("input_schema", value.get("inputSchema", {}))
|
|
61
|
+
or {}
|
|
62
|
+
),
|
|
63
|
+
risk=_coerce_risk(risk),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@runtime_checkable
|
|
68
|
+
class MCPClient(Protocol):
|
|
69
|
+
"""Small client contract required by :func:`adapt_mcp_tools`.
|
|
70
|
+
|
|
71
|
+
``list_tools`` is synchronous so runtime construction remains synchronous;
|
|
72
|
+
``call_tool`` may return a value or an awaitable.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def list_tools(self) -> Iterable[MCPToolSpec | Mapping[str, Any]]: ...
|
|
76
|
+
|
|
77
|
+
def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: ...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MCPToolAdapter(BaseTool):
|
|
81
|
+
"""Expose one MCP tool through MindAgent's ordinary ``BaseTool`` API."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
client: MCPClient,
|
|
86
|
+
spec: MCPToolSpec | Mapping[str, Any],
|
|
87
|
+
*,
|
|
88
|
+
namespace: str | None = None,
|
|
89
|
+
close_client: bool = False,
|
|
90
|
+
):
|
|
91
|
+
self.client = client
|
|
92
|
+
self.spec = _coerce_spec(spec)
|
|
93
|
+
self._close_client = close_client
|
|
94
|
+
public_name = self.spec.name
|
|
95
|
+
if namespace:
|
|
96
|
+
public_name = f"{namespace}_{public_name}"
|
|
97
|
+
self.definition = ToolDefinition(
|
|
98
|
+
name=public_name,
|
|
99
|
+
description=self.spec.description or f"MCP tool {self.spec.name}",
|
|
100
|
+
parameters=self.spec.input_schema,
|
|
101
|
+
risk=self.spec.risk,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def execute(
|
|
105
|
+
self,
|
|
106
|
+
arguments: dict[str, Any],
|
|
107
|
+
context: ToolContext,
|
|
108
|
+
) -> Any:
|
|
109
|
+
result = self.client.call_tool(self.spec.name, arguments)
|
|
110
|
+
if inspect.isawaitable(result):
|
|
111
|
+
return await result
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
async def close(self) -> None:
|
|
115
|
+
if not self._close_client:
|
|
116
|
+
return
|
|
117
|
+
self._close_client = False
|
|
118
|
+
close = getattr(self.client, "close", None)
|
|
119
|
+
if close is None:
|
|
120
|
+
close = getattr(self.client, "aclose", None)
|
|
121
|
+
if callable(close):
|
|
122
|
+
result = close()
|
|
123
|
+
if inspect.isawaitable(result):
|
|
124
|
+
await result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def adapt_mcp_tools(
|
|
128
|
+
client: MCPClient,
|
|
129
|
+
*,
|
|
130
|
+
namespace: str | None = None,
|
|
131
|
+
) -> list[MCPToolAdapter]:
|
|
132
|
+
"""Discover a synchronous MCP client and return ordinary BaseTool adapters."""
|
|
133
|
+
listed = client.list_tools()
|
|
134
|
+
if inspect.isawaitable(listed):
|
|
135
|
+
raise TypeError(
|
|
136
|
+
"MCPClient.list_tools() 必须同步;异步客户端请先在宿主侧发现工具"
|
|
137
|
+
)
|
|
138
|
+
if isinstance(listed, Mapping):
|
|
139
|
+
listed = listed.get("tools", ())
|
|
140
|
+
specs = [_coerce_spec(value) for value in listed]
|
|
141
|
+
return [
|
|
142
|
+
MCPToolAdapter(
|
|
143
|
+
client,
|
|
144
|
+
spec,
|
|
145
|
+
namespace=namespace,
|
|
146
|
+
close_client=index == 0,
|
|
147
|
+
)
|
|
148
|
+
for index, spec in enumerate(specs)
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def aload_mcp_tools(
|
|
153
|
+
client: MCPClient,
|
|
154
|
+
*,
|
|
155
|
+
namespace: str | None = None,
|
|
156
|
+
) -> list[MCPToolAdapter]:
|
|
157
|
+
"""Async counterpart for clients whose tool discovery is awaitable."""
|
|
158
|
+
listed = client.list_tools()
|
|
159
|
+
if inspect.isawaitable(listed):
|
|
160
|
+
listed = await listed
|
|
161
|
+
if isinstance(listed, Mapping):
|
|
162
|
+
listed = listed.get("tools", ())
|
|
163
|
+
specs = [_coerce_spec(value) for value in listed]
|
|
164
|
+
return [
|
|
165
|
+
MCPToolAdapter(
|
|
166
|
+
client,
|
|
167
|
+
spec,
|
|
168
|
+
namespace=namespace,
|
|
169
|
+
close_client=index == 0,
|
|
170
|
+
)
|
|
171
|
+
for index, spec in enumerate(specs)
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _coerce_spec(value: MCPToolSpec | Mapping[str, Any]) -> MCPToolSpec:
|
|
176
|
+
if isinstance(value, MCPToolSpec):
|
|
177
|
+
return value
|
|
178
|
+
if isinstance(value, Mapping):
|
|
179
|
+
return MCPToolSpec.from_mapping(value)
|
|
180
|
+
raise TypeError("MCP tools 必须是 MCPToolSpec 或 mapping")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _coerce_risk(value: Any) -> ActionRisk:
|
|
184
|
+
if isinstance(value, ActionRisk):
|
|
185
|
+
return value
|
|
186
|
+
if value is None:
|
|
187
|
+
return ActionRisk.WRITE
|
|
188
|
+
try:
|
|
189
|
+
return ActionRisk(str(value).lower())
|
|
190
|
+
except ValueError as exc:
|
|
191
|
+
raise ValueError(f"未知 MCP tool risk: {value}") from exc
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Common spelling for callers that model MCP's tool description directly.
|
|
195
|
+
MCPToolDefinition = MCPToolSpec
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
__all__ = [
|
|
199
|
+
"MCPClient",
|
|
200
|
+
"MCPToolAdapter",
|
|
201
|
+
"MCPToolDefinition",
|
|
202
|
+
"MCPToolSpec",
|
|
203
|
+
"adapt_mcp_tools",
|
|
204
|
+
"aload_mcp_tools",
|
|
205
|
+
]
|
mindcode/policy.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from mindagent.core import (
|
|
7
|
+
ActionRequest,
|
|
8
|
+
ActionRisk,
|
|
9
|
+
AgentContext,
|
|
10
|
+
ValidationResult,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApprovalMode(str, Enum):
|
|
15
|
+
"""The two supported MindCode permission modes.
|
|
16
|
+
|
|
17
|
+
``ASK`` and ``AUTO`` remain enum aliases so sessions created by older
|
|
18
|
+
MindCode versions keep working. Their values are the new canonical
|
|
19
|
+
``ask_user`` and ``full_accept`` values. Short CLI spellings are handled
|
|
20
|
+
by ``_missing_`` for the same migration reason.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
ASK_USER = "ask_user"
|
|
24
|
+
FULL_ACCEPT = "full_accept"
|
|
25
|
+
|
|
26
|
+
# Source compatibility for callers that imported the old names.
|
|
27
|
+
ASK = ASK_USER
|
|
28
|
+
AUTO = FULL_ACCEPT
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def _missing_(cls, value: object) -> "ApprovalMode | None":
|
|
32
|
+
if not isinstance(value, str):
|
|
33
|
+
return None
|
|
34
|
+
aliases = {
|
|
35
|
+
"ask": cls.ASK_USER,
|
|
36
|
+
"full": cls.FULL_ACCEPT,
|
|
37
|
+
"full-access": cls.FULL_ACCEPT,
|
|
38
|
+
"auto": cls.FULL_ACCEPT,
|
|
39
|
+
}
|
|
40
|
+
return aliases.get(value.strip().lower())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def approval_mode_label(mode: ApprovalMode | str) -> str:
|
|
44
|
+
"""Return the user-facing name for an approval mode."""
|
|
45
|
+
selected = ApprovalMode(mode)
|
|
46
|
+
if selected is ApprovalMode.ASK_USER:
|
|
47
|
+
return "Ask User"
|
|
48
|
+
return "Full Accept"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
ApprovalCallback = Callable[[AgentContext, ActionRequest], Awaitable[bool]]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_session_policy(
|
|
55
|
+
context: AgentContext,
|
|
56
|
+
action: ActionRequest,
|
|
57
|
+
) -> ValidationResult | None:
|
|
58
|
+
"""Enforce session-scoped capability limits before mode approval.
|
|
59
|
+
|
|
60
|
+
MindCode's policy is the first extension invoked by ``AgentRuntime`` when
|
|
61
|
+
a custom policy is installed, so it must retain the same capability
|
|
62
|
+
checks as MindAgent's default policy instead of relying on a separate
|
|
63
|
+
``AgentSession`` wrapper.
|
|
64
|
+
"""
|
|
65
|
+
session_policy = context.session_policy
|
|
66
|
+
if session_policy is None:
|
|
67
|
+
return None
|
|
68
|
+
if (
|
|
69
|
+
session_policy.allowed_action_types is not None
|
|
70
|
+
and action.action_type not in session_policy.allowed_action_types
|
|
71
|
+
):
|
|
72
|
+
return ValidationResult(False, "当前 session 不允许该 action 类型")
|
|
73
|
+
if (
|
|
74
|
+
session_policy.allowed_tools is not None
|
|
75
|
+
and action.name not in session_policy.allowed_tools
|
|
76
|
+
):
|
|
77
|
+
return ValidationResult(
|
|
78
|
+
False,
|
|
79
|
+
f"当前 session 不允许工具: {action.name}",
|
|
80
|
+
)
|
|
81
|
+
if action.risk == ActionRisk.WRITE and not session_policy.allow_write:
|
|
82
|
+
return ValidationResult(False, "当前 session 不允许写操作")
|
|
83
|
+
if (
|
|
84
|
+
action.risk == ActionRisk.DANGEROUS
|
|
85
|
+
and not session_policy.allow_dangerous
|
|
86
|
+
):
|
|
87
|
+
return ValidationResult(False, "当前 session 不允许危险操作")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _default_approval_callback(
|
|
92
|
+
context: AgentContext,
|
|
93
|
+
action: ActionRequest,
|
|
94
|
+
) -> bool:
|
|
95
|
+
"""Ask in the MindCode shell when no host callback was supplied."""
|
|
96
|
+
# Lazy import avoids importing the CLI (and prompt_toolkit) for library
|
|
97
|
+
# users that construct a policy in a non-interactive process.
|
|
98
|
+
from .approval import describe_action
|
|
99
|
+
from .cli.shell.menu import prompt_yes_no
|
|
100
|
+
|
|
101
|
+
summary = describe_action(action)
|
|
102
|
+
return await prompt_yes_no(f"允许执行 {summary}?", default_yes=False)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AskUserPolicy:
|
|
106
|
+
"""Allow reads and ask the user before writes or dangerous actions.
|
|
107
|
+
|
|
108
|
+
注意:validate_action 内阻塞式 confirm 是产品层授权等待(用户决策),
|
|
109
|
+
已豁免 step 超时、受 total_timeout 监督。未来 Policy 解耦(deferred +
|
|
110
|
+
挂起 validation 待审批后恢复)需 core 支持挂起/恢复机制,本次不做。
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, confirm: ApprovalCallback | None = None):
|
|
114
|
+
self._confirm = confirm or _default_approval_callback
|
|
115
|
+
|
|
116
|
+
async def validate_action(
|
|
117
|
+
self,
|
|
118
|
+
context: AgentContext,
|
|
119
|
+
action: ActionRequest,
|
|
120
|
+
) -> ValidationResult:
|
|
121
|
+
session_result = _validate_session_policy(context, action)
|
|
122
|
+
if session_result is not None:
|
|
123
|
+
return session_result
|
|
124
|
+
if action.risk == ActionRisk.READ_ONLY:
|
|
125
|
+
return ValidationResult(allowed=True, reason="只读动作已授权")
|
|
126
|
+
approved = await self._confirm(context, action)
|
|
127
|
+
if approved:
|
|
128
|
+
return ValidationResult(
|
|
129
|
+
allowed=True,
|
|
130
|
+
reason=f"用户已批准 {action.risk.value} 操作",
|
|
131
|
+
)
|
|
132
|
+
return ValidationResult(
|
|
133
|
+
allowed=False,
|
|
134
|
+
reason=f"用户拒绝执行 {action.risk.value} 操作",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class FullAcceptPolicy:
|
|
139
|
+
"""Allow every action selected by the current session policy."""
|
|
140
|
+
|
|
141
|
+
async def validate_action(
|
|
142
|
+
self,
|
|
143
|
+
context: AgentContext,
|
|
144
|
+
action: ActionRequest,
|
|
145
|
+
) -> ValidationResult:
|
|
146
|
+
session_result = _validate_session_policy(context, action)
|
|
147
|
+
if session_result is not None:
|
|
148
|
+
return session_result
|
|
149
|
+
return ValidationResult(allowed=True, reason="full access 已授权")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def make_policy(
|
|
153
|
+
mode: ApprovalMode | str,
|
|
154
|
+
*,
|
|
155
|
+
approval_callback: ApprovalCallback | None = None,
|
|
156
|
+
) -> AskUserPolicy | FullAcceptPolicy:
|
|
157
|
+
"""Build the product policy for ``mode``.
|
|
158
|
+
|
|
159
|
+
``approval_callback`` is useful for a host UI or deterministic tests. The
|
|
160
|
+
interactive shell gets the default callback automatically.
|
|
161
|
+
"""
|
|
162
|
+
selected = ApprovalMode(mode)
|
|
163
|
+
if selected is ApprovalMode.ASK_USER:
|
|
164
|
+
return AskUserPolicy(approval_callback)
|
|
165
|
+
if selected is ApprovalMode.FULL_ACCEPT:
|
|
166
|
+
return FullAcceptPolicy()
|
|
167
|
+
raise ValueError(f"未知 ApprovalMode: {mode}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Compatibility names for callers that imported the old policy classes. They
|
|
171
|
+
# intentionally map to the new two-mode semantics; there is no suggest mode.
|
|
172
|
+
AskPolicy = AskUserPolicy
|
|
173
|
+
AutoPolicy = FullAcceptPolicy
|