mandri 0.1.0a1__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.
- mandri/__init__.py +0 -0
- mandri/adapters/__init__.py +0 -0
- mandri/adapters/approval/__init__.py +9 -0
- mandri/adapters/approval/autopilot.py +150 -0
- mandri/adapters/approval/batcher.py +54 -0
- mandri/adapters/approval/manual.py +19 -0
- mandri/adapters/approval/yolo.py +9 -0
- mandri/adapters/config/__init__.py +3 -0
- mandri/adapters/config/toml/__init__.py +39 -0
- mandri/adapters/config/toml/decode.py +195 -0
- mandri/adapters/config/toml/encode.py +95 -0
- mandri/adapters/config/toml/store.py +37 -0
- mandri/adapters/credentials/__init__.py +4 -0
- mandri/adapters/credentials/permissions.py +26 -0
- mandri/adapters/credentials/toml.py +40 -0
- mandri/adapters/credentials/windows_acl.py +209 -0
- mandri/adapters/credentials/windows_dacl.py +54 -0
- mandri/adapters/filesystem/__init__.py +3 -0
- mandri/adapters/filesystem/atomic.py +25 -0
- mandri/adapters/llm/__init__.py +12 -0
- mandri/adapters/llm/lmstudio.py +53 -0
- mandri/adapters/llm/openai_compatible/__init__.py +17 -0
- mandri/adapters/llm/openai_compatible/constants.py +9 -0
- mandri/adapters/llm/openai_compatible/http_client.py +97 -0
- mandri/adapters/llm/openai_compatible/provider.py +140 -0
- mandri/adapters/llm/openai_compatible/request_codec.py +222 -0
- mandri/adapters/llm/openai_compatible/response_codec.py +59 -0
- mandri/adapters/llm/openai_compatible/stream.py +159 -0
- mandri/adapters/llm/openai_compatible/values.py +30 -0
- mandri/adapters/llm/opencode.py +39 -0
- mandri/adapters/llm/openrouter.py +110 -0
- mandri/adapters/llm/sse.py +14 -0
- mandri/adapters/memory/__init__.py +3 -0
- mandri/adapters/memory/filesystem.py +42 -0
- mandri/adapters/memory_search/__init__.py +3 -0
- mandri/adapters/memory_search/filesystem.py +52 -0
- mandri/adapters/question/__init__.py +10 -0
- mandri/adapters/question/autopilot.py +164 -0
- mandri/adapters/question/manual.py +13 -0
- mandri/adapters/question/yolo.py +20 -0
- mandri/adapters/retry/__init__.py +5 -0
- mandri/adapters/retry/failfast.py +10 -0
- mandri/adapters/session/__init__.py +3 -0
- mandri/adapters/session/filesystem/__init__.py +45 -0
- mandri/adapters/session/filesystem/codec.py +186 -0
- mandri/adapters/session/filesystem/store.py +91 -0
- mandri/adapters/session/filesystem/values.py +46 -0
- mandri/adapters/subagent/__init__.py +12 -0
- mandri/adapters/subagent/claude/__init__.py +83 -0
- mandri/adapters/subagent/claude/approval.py +91 -0
- mandri/adapters/subagent/claude/config.py +47 -0
- mandri/adapters/subagent/claude/constants.py +18 -0
- mandri/adapters/subagent/claude/factory.py +94 -0
- mandri/adapters/subagent/claude/process.py +50 -0
- mandri/adapters/subagent/claude/questions.py +64 -0
- mandri/adapters/subagent/claude/relay.py +62 -0
- mandri/adapters/subagent/claude/subagent.py +255 -0
- mandri/adapters/subagent/claude/translation.py +123 -0
- mandri/adapters/subagent/codex/__init__.py +87 -0
- mandri/adapters/subagent/codex/client.py +218 -0
- mandri/adapters/subagent/codex/config.py +128 -0
- mandri/adapters/subagent/codex/constants.py +2 -0
- mandri/adapters/subagent/codex/decisions.py +113 -0
- mandri/adapters/subagent/codex/factory.py +96 -0
- mandri/adapters/subagent/codex/process.py +94 -0
- mandri/adapters/subagent/codex/subagent.py +221 -0
- mandri/adapters/subagent/codex/translation.py +101 -0
- mandri/adapters/subagent/codex_workspace.py +75 -0
- mandri/adapters/subagent/opencode/__init__.py +41 -0
- mandri/adapters/subagent/opencode/client.py +246 -0
- mandri/adapters/subagent/opencode/config.py +53 -0
- mandri/adapters/subagent/opencode/constants.py +2 -0
- mandri/adapters/subagent/opencode/factory.py +114 -0
- mandri/adapters/subagent/opencode/subagent.py +178 -0
- mandri/adapters/subagent/opencode/translation.py +127 -0
- mandri/adapters/user_profile/__init__.py +3 -0
- mandri/adapters/user_profile/filesystem.py +28 -0
- mandri/core/__init__.py +89 -0
- mandri/core/agent/__init__.py +19 -0
- mandri/core/agent/blackboard.py +25 -0
- mandri/core/agent/context.py +33 -0
- mandri/core/agent/input.py +13 -0
- mandri/core/agent/loop.py +590 -0
- mandri/core/agent/manager.py +313 -0
- mandri/core/agent/output.py +51 -0
- mandri/core/agent/runtime.py +59 -0
- mandri/core/agent/session.py +278 -0
- mandri/core/agent/steering.py +29 -0
- mandri/core/background/__init__.py +13 -0
- mandri/core/background/manager.py +260 -0
- mandri/core/session/__init__.py +3 -0
- mandri/core/session/saver.py +60 -0
- mandri/core/tools/__init__.py +19 -0
- mandri/core/tools/agent/__init__.py +13 -0
- mandri/core/tools/agent/result.py +28 -0
- mandri/core/tools/agent/send.py +45 -0
- mandri/core/tools/agent/spawn.py +54 -0
- mandri/core/tools/agent/status.py +38 -0
- mandri/core/tools/agent/stop.py +38 -0
- mandri/core/tools/agent/wait.py +38 -0
- mandri/core/tools/executor.py +104 -0
- mandri/core/tools/registry.py +29 -0
- mandri/core/user_profile/__init__.py +3 -0
- mandri/core/user_profile/profile_updater.py +136 -0
- mandri/gateway/__init__.py +5 -0
- mandri/gateway/anthropic/__init__.py +61 -0
- mandri/gateway/anthropic/request.py +249 -0
- mandri/gateway/anthropic/response.py +96 -0
- mandri/gateway/anthropic/stream.py +130 -0
- mandri/gateway/anthropic/values.py +82 -0
- mandri/gateway/decode.py +240 -0
- mandri/gateway/encode.py +198 -0
- mandri/gateway/errors.py +43 -0
- mandri/gateway/execution.py +121 -0
- mandri/gateway/handlers.py +157 -0
- mandri/gateway/http.py +85 -0
- mandri/gateway/responses/__init__.py +92 -0
- mandri/gateway/responses/messages.py +190 -0
- mandri/gateway/responses/request.py +143 -0
- mandri/gateway/responses/response.py +143 -0
- mandri/gateway/responses/stream.py +304 -0
- mandri/gateway/responses/tools.py +155 -0
- mandri/gateway/responses/tunnel.py +16 -0
- mandri/gateway/responses/values.py +106 -0
- mandri/gateway/routing.py +104 -0
- mandri/gateway/server.py +109 -0
- mandri/gateway/wire.py +12 -0
- mandri/logging.py +82 -0
- mandri/mcp/__init__.py +3 -0
- mandri/mcp/server.py +131 -0
- mandri/ports/__init__.py +0 -0
- mandri/ports/agent/__init__.py +61 -0
- mandri/ports/agent/context.py +7 -0
- mandri/ports/agent/events.py +171 -0
- mandri/ports/agent/input.py +9 -0
- mandri/ports/agent/runtime.py +25 -0
- mandri/ports/approval/__init__.py +7 -0
- mandri/ports/approval/port.py +23 -0
- mandri/ports/background/__init__.py +8 -0
- mandri/ports/background/handle.py +34 -0
- mandri/ports/background/port.py +66 -0
- mandri/ports/config/__init__.py +31 -0
- mandri/ports/config/port.py +212 -0
- mandri/ports/credentials/__init__.py +3 -0
- mandri/ports/credentials/port.py +7 -0
- mandri/ports/gateway/__init__.py +8 -0
- mandri/ports/gateway/port.py +32 -0
- mandri/ports/llm/__init__.py +90 -0
- mandri/ports/llm/capabilities.py +25 -0
- mandri/ports/llm/content.py +65 -0
- mandri/ports/llm/errors.py +44 -0
- mandri/ports/llm/messages.py +19 -0
- mandri/ports/llm/output.py +16 -0
- mandri/ports/llm/passthrough.py +5 -0
- mandri/ports/llm/port.py +20 -0
- mandri/ports/llm/request.py +32 -0
- mandri/ports/llm/response.py +36 -0
- mandri/ports/llm/stream.py +61 -0
- mandri/ports/llm/tools.py +28 -0
- mandri/ports/mcp/__init__.py +3 -0
- mandri/ports/mcp/port.py +12 -0
- mandri/ports/memory/__init__.py +3 -0
- mandri/ports/memory/port.py +28 -0
- mandri/ports/memory_search/__init__.py +3 -0
- mandri/ports/memory_search/port.py +9 -0
- mandri/ports/question/__init__.py +19 -0
- mandri/ports/question/models.py +36 -0
- mandri/ports/question/port.py +18 -0
- mandri/ports/retry/__init__.py +6 -0
- mandri/ports/retry/port.py +10 -0
- mandri/ports/session/__init__.py +3 -0
- mandri/ports/session/port.py +30 -0
- mandri/ports/subagent/__init__.py +24 -0
- mandri/ports/subagent/observer.py +16 -0
- mandri/ports/subagent/port.py +70 -0
- mandri/ports/tools/__init__.py +10 -0
- mandri/ports/tools/executor.py +15 -0
- mandri/ports/tools/port.py +32 -0
- mandri/ports/user_profile/__init__.py +3 -0
- mandri/ports/user_profile/port.py +16 -0
- mandri/prompts/__init__.py +3 -0
- mandri/prompts/approval.py +8 -0
- mandri/prompts/question.py +3 -0
- mandri/prompts/system_prompt.py +37 -0
- mandri/prompts/user_profile.py +5 -0
- mandri/py.typed +0 -0
- mandri-0.1.0a1.dist-info/METADATA +74 -0
- mandri-0.1.0a1.dist-info/RECORD +228 -0
- mandri-0.1.0a1.dist-info/WHEEL +4 -0
- mandri-0.1.0a1.dist-info/entry_points.txt +2 -0
- mandri-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- mandri_tui/__init__.py +3 -0
- mandri_tui/__main__.py +27 -0
- mandri_tui/app/__init__.py +22 -0
- mandri_tui/app/base.py +301 -0
- mandri_tui/app/input.py +186 -0
- mandri_tui/app/lifecycle.py +157 -0
- mandri_tui/app/models.py +255 -0
- mandri_tui/app/observer.py +175 -0
- mandri_tui/app/sessions.py +217 -0
- mandri_tui/app/startup.py +229 -0
- mandri_tui/app/turns.py +63 -0
- mandri_tui/approval.py +170 -0
- mandri_tui/attachments.py +153 -0
- mandri_tui/background_indicator.py +183 -0
- mandri_tui/blocks.py +234 -0
- mandri_tui/catalog.py +165 -0
- mandri_tui/commands.py +90 -0
- mandri_tui/composer.py +84 -0
- mandri_tui/formatting.py +89 -0
- mandri_tui/model_selection.py +43 -0
- mandri_tui/runtime.py +232 -0
- mandri_tui/screens/__init__.py +18 -0
- mandri_tui/screens/approval.py +82 -0
- mandri_tui/screens/model.py +128 -0
- mandri_tui/screens/provider.py +102 -0
- mandri_tui/screens/question.py +79 -0
- mandri_tui/screens/session.py +53 -0
- mandri_tui/suggestions.py +13 -0
- mandri_tui/text_attachments.py +94 -0
- mandri_tui/transcript/__init__.py +6 -0
- mandri_tui/transcript/log.py +90 -0
- mandri_tui/transcript/renderer.py +311 -0
- mandri_tui/transcript/screen.py +44 -0
- mandri_tui/transcript/subagent.py +105 -0
- mandri_tui/tui.tcss +983 -0
- mandri_tui/welcome.py +72 -0
- mandri_tui/wiring.py +42 -0
mandri/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from mandri.ports.approval import ApprovalDecision
|
|
5
|
+
from mandri.ports.llm import (
|
|
6
|
+
JsonObjectOutput,
|
|
7
|
+
LLMProviderPort,
|
|
8
|
+
LLMRequest,
|
|
9
|
+
Message,
|
|
10
|
+
Role,
|
|
11
|
+
TextPart,
|
|
12
|
+
ToolCallPart,
|
|
13
|
+
)
|
|
14
|
+
from mandri.prompts.approval import AUTOPILOT_APPROVAL_PROMPT
|
|
15
|
+
|
|
16
|
+
_logger = logging.getLogger(__name__)
|
|
17
|
+
_SAFE_RISKS = frozenset(("LOW", "MEDIUM"))
|
|
18
|
+
_DECISION_SCHEMA = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"tool_call_id": {"type": "string"},
|
|
22
|
+
"risk": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"enum": ["LOW", "MEDIUM", "HIGH", "UNKNOWN"],
|
|
25
|
+
},
|
|
26
|
+
"reason": {"type": "string"},
|
|
27
|
+
},
|
|
28
|
+
"required": ["tool_call_id", "risk", "reason"],
|
|
29
|
+
"additionalProperties": False,
|
|
30
|
+
}
|
|
31
|
+
_VERDICT_SCHEMA = {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"decisions": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": _DECISION_SCHEMA,
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"required": ["decisions"],
|
|
40
|
+
"additionalProperties": False,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AutopilotApprovalPolicy:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
provider: LLMProviderPort,
|
|
48
|
+
*,
|
|
49
|
+
model: str,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._provider = provider
|
|
52
|
+
self._model = model
|
|
53
|
+
|
|
54
|
+
async def request(
|
|
55
|
+
self, calls: tuple[ToolCallPart, ...]
|
|
56
|
+
) -> tuple[ApprovalDecision, ...]:
|
|
57
|
+
if not calls:
|
|
58
|
+
return ()
|
|
59
|
+
try:
|
|
60
|
+
response = await self._provider.generate(self._request(calls))
|
|
61
|
+
verdicts = _verdicts(response.message, calls)
|
|
62
|
+
except Exception:
|
|
63
|
+
_logger.exception(
|
|
64
|
+
"autopilot approval failed tools=%s",
|
|
65
|
+
",".join(call.tool_name for call in calls),
|
|
66
|
+
)
|
|
67
|
+
return tuple(
|
|
68
|
+
ApprovalDecision(
|
|
69
|
+
approved=False,
|
|
70
|
+
feedback="Autopilot could not assess this tool call",
|
|
71
|
+
)
|
|
72
|
+
for _ in calls
|
|
73
|
+
)
|
|
74
|
+
return tuple(_decision(risk, reason) for risk, reason in verdicts)
|
|
75
|
+
|
|
76
|
+
def _request(self, calls: tuple[ToolCallPart, ...]) -> LLMRequest:
|
|
77
|
+
payload = json.dumps(
|
|
78
|
+
{
|
|
79
|
+
"tool_calls": [
|
|
80
|
+
{
|
|
81
|
+
"tool_call_id": call.tool_call_id,
|
|
82
|
+
"tool": call.tool_name,
|
|
83
|
+
"arguments": call.arguments,
|
|
84
|
+
}
|
|
85
|
+
for call in calls
|
|
86
|
+
],
|
|
87
|
+
"output_format": "json",
|
|
88
|
+
"output_schema": _VERDICT_SCHEMA,
|
|
89
|
+
},
|
|
90
|
+
ensure_ascii=False,
|
|
91
|
+
)
|
|
92
|
+
return LLMRequest(
|
|
93
|
+
model=self._model,
|
|
94
|
+
messages=(
|
|
95
|
+
Message(
|
|
96
|
+
role=Role.SYSTEM,
|
|
97
|
+
parts=(TextPart(text=AUTOPILOT_APPROVAL_PROMPT),),
|
|
98
|
+
),
|
|
99
|
+
Message(role=Role.USER, parts=(TextPart(text=payload),)),
|
|
100
|
+
),
|
|
101
|
+
output=JsonObjectOutput(),
|
|
102
|
+
max_output_tokens=4096,
|
|
103
|
+
temperature=0.0,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _verdicts(
|
|
108
|
+
message: Message, calls: tuple[ToolCallPart, ...]
|
|
109
|
+
) -> tuple[tuple[str, str], ...]:
|
|
110
|
+
content = "".join(part.text for part in message.parts if isinstance(part, TextPart))
|
|
111
|
+
verdict = json.loads(content)
|
|
112
|
+
if not isinstance(verdict, dict):
|
|
113
|
+
raise ValueError("autopilot verdict must be an object")
|
|
114
|
+
decisions = verdict.get("decisions")
|
|
115
|
+
if not isinstance(decisions, list) or len(decisions) != len(calls):
|
|
116
|
+
raise ValueError("autopilot verdict count does not match tool calls")
|
|
117
|
+
by_id = {_decision_id(item): item for item in decisions}
|
|
118
|
+
if len(by_id) != len(decisions):
|
|
119
|
+
raise ValueError("autopilot verdict has duplicate tool call ids")
|
|
120
|
+
return tuple(_risk_reason(by_id.get(call.tool_call_id)) for call in calls)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _decision_id(verdict: object) -> str:
|
|
124
|
+
if not isinstance(verdict, dict):
|
|
125
|
+
raise ValueError("autopilot decision must be an object")
|
|
126
|
+
tool_call_id = verdict.get("tool_call_id")
|
|
127
|
+
if not isinstance(tool_call_id, str) or not tool_call_id:
|
|
128
|
+
raise ValueError("autopilot decision has no tool call id")
|
|
129
|
+
return tool_call_id
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _risk_reason(verdict: object) -> tuple[str, str]:
|
|
133
|
+
if not isinstance(verdict, dict):
|
|
134
|
+
raise ValueError("autopilot decision must be an object")
|
|
135
|
+
risk = verdict.get("risk")
|
|
136
|
+
reason = verdict.get("reason")
|
|
137
|
+
if risk not in {"LOW", "MEDIUM", "HIGH", "UNKNOWN"}:
|
|
138
|
+
raise ValueError("autopilot verdict has an invalid risk")
|
|
139
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
140
|
+
raise ValueError("autopilot verdict has no reason")
|
|
141
|
+
return risk, reason.strip()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _decision(risk: str, reason: str) -> ApprovalDecision:
|
|
145
|
+
if risk in _SAFE_RISKS:
|
|
146
|
+
return ApprovalDecision(approved=True)
|
|
147
|
+
return ApprovalDecision(
|
|
148
|
+
approved=False,
|
|
149
|
+
feedback=f"Autopilot classified this tool call as {risk}: {reason}",
|
|
150
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from mandri.ports.approval import ApprovalDecision, ApprovalPolicyPort
|
|
4
|
+
from mandri.ports.llm import ToolCallPart
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ApprovalBatcher:
|
|
8
|
+
def __init__(self, policy: ApprovalPolicyPort) -> None:
|
|
9
|
+
self._policy = policy
|
|
10
|
+
self._decisions: dict[str, ApprovalDecision] = {}
|
|
11
|
+
self._lock = asyncio.Lock()
|
|
12
|
+
|
|
13
|
+
def set_policy(self, policy: ApprovalPolicyPort) -> None:
|
|
14
|
+
self._policy = policy
|
|
15
|
+
|
|
16
|
+
async def request(
|
|
17
|
+
self,
|
|
18
|
+
call: ToolCallPart,
|
|
19
|
+
batch: tuple[ToolCallPart, ...] = (),
|
|
20
|
+
) -> ApprovalDecision:
|
|
21
|
+
async with self._lock:
|
|
22
|
+
cached = self._decisions.pop(call.tool_call_id, None)
|
|
23
|
+
if cached is not None:
|
|
24
|
+
return cached
|
|
25
|
+
calls = self._unresolved(call, batch)
|
|
26
|
+
decisions = await self._policy.request(calls)
|
|
27
|
+
if len(decisions) != len(calls):
|
|
28
|
+
raise ValueError("approval decision count does not match calls")
|
|
29
|
+
self._decisions.update(
|
|
30
|
+
(candidate.tool_call_id, decision)
|
|
31
|
+
for candidate, decision in zip(calls, decisions, strict=True)
|
|
32
|
+
)
|
|
33
|
+
decision = self._decisions.pop(call.tool_call_id, None)
|
|
34
|
+
if decision is None:
|
|
35
|
+
raise ValueError("approval batch does not contain requested call")
|
|
36
|
+
return decision
|
|
37
|
+
|
|
38
|
+
def discard(self, tool_call_id: str) -> None:
|
|
39
|
+
self._decisions.pop(tool_call_id, None)
|
|
40
|
+
|
|
41
|
+
def clear(self) -> None:
|
|
42
|
+
self._decisions.clear()
|
|
43
|
+
|
|
44
|
+
def _unresolved(
|
|
45
|
+
self,
|
|
46
|
+
call: ToolCallPart,
|
|
47
|
+
batch: tuple[ToolCallPart, ...],
|
|
48
|
+
) -> tuple[ToolCallPart, ...]:
|
|
49
|
+
calls = {
|
|
50
|
+
candidate.tool_call_id: candidate
|
|
51
|
+
for candidate in (*batch, call)
|
|
52
|
+
if candidate.tool_call_id not in self._decisions
|
|
53
|
+
}
|
|
54
|
+
return tuple(calls.values())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
|
|
3
|
+
from mandri.ports.approval import ApprovalDecision
|
|
4
|
+
from mandri.ports.llm import ToolCallPart
|
|
5
|
+
|
|
6
|
+
type ApprovalPrompt = Callable[[ToolCallPart], Awaitable[ApprovalDecision]]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ManualApprovalPolicy:
|
|
10
|
+
def __init__(self, prompt: ApprovalPrompt) -> None:
|
|
11
|
+
self._prompt = prompt
|
|
12
|
+
|
|
13
|
+
async def request(
|
|
14
|
+
self, calls: tuple[ToolCallPart, ...]
|
|
15
|
+
) -> tuple[ApprovalDecision, ...]:
|
|
16
|
+
decisions: list[ApprovalDecision] = []
|
|
17
|
+
for call in calls:
|
|
18
|
+
decisions.append(await self._prompt(call))
|
|
19
|
+
return tuple(decisions)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from mandri.ports.approval import ApprovalDecision
|
|
2
|
+
from mandri.ports.llm import ToolCallPart
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class YoloApprovalPolicy:
|
|
6
|
+
async def request(
|
|
7
|
+
self, calls: tuple[ToolCallPart, ...]
|
|
8
|
+
) -> tuple[ApprovalDecision, ...]:
|
|
9
|
+
return tuple(ApprovalDecision(approved=True) for _ in calls)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from .decode import (
|
|
2
|
+
_approval_mode,
|
|
3
|
+
_model_config,
|
|
4
|
+
_non_negative_int,
|
|
5
|
+
_optional_string,
|
|
6
|
+
_orchestrator,
|
|
7
|
+
_positive_int,
|
|
8
|
+
_providers,
|
|
9
|
+
_required_string,
|
|
10
|
+
_role,
|
|
11
|
+
_table,
|
|
12
|
+
_user_profile,
|
|
13
|
+
from_toml,
|
|
14
|
+
)
|
|
15
|
+
from .encode import (
|
|
16
|
+
_serialize_role,
|
|
17
|
+
to_toml,
|
|
18
|
+
)
|
|
19
|
+
from .store import (
|
|
20
|
+
TomlConfig,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"TomlConfig",
|
|
25
|
+
"_approval_mode",
|
|
26
|
+
"_model_config",
|
|
27
|
+
"_non_negative_int",
|
|
28
|
+
"_optional_string",
|
|
29
|
+
"_orchestrator",
|
|
30
|
+
"_positive_int",
|
|
31
|
+
"_providers",
|
|
32
|
+
"_required_string",
|
|
33
|
+
"_role",
|
|
34
|
+
"_serialize_role",
|
|
35
|
+
"_table",
|
|
36
|
+
"_user_profile",
|
|
37
|
+
"from_toml",
|
|
38
|
+
"to_toml",
|
|
39
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from typing import Any, Literal, cast
|
|
2
|
+
|
|
3
|
+
from mandri.ports.approval import ApprovalMode
|
|
4
|
+
from mandri.ports.config import (
|
|
5
|
+
AgentConfig,
|
|
6
|
+
ApprovalConfig,
|
|
7
|
+
AttachmentConfig,
|
|
8
|
+
BackgroundConfig,
|
|
9
|
+
MandriConfig,
|
|
10
|
+
ModelConfig,
|
|
11
|
+
ModelRole,
|
|
12
|
+
ProviderConfig,
|
|
13
|
+
ProviderKind,
|
|
14
|
+
ToolTimeoutOverride,
|
|
15
|
+
UserPreferences,
|
|
16
|
+
UserProfileConfig,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def from_toml(data: dict[str, Any]) -> MandriConfig:
|
|
21
|
+
user_profile = _user_profile(data)
|
|
22
|
+
attachments = _table(data, "attachments")
|
|
23
|
+
agents = _table(data, "agents")
|
|
24
|
+
approval = _table(data, "approval")
|
|
25
|
+
background = _table(data, "background")
|
|
26
|
+
tool_timeouts = _table(data, "tool_timeouts")
|
|
27
|
+
user_preferences = _table(data, "user_preferences")
|
|
28
|
+
models = _table(data, "models")
|
|
29
|
+
orchestrator = _orchestrator(data, models)
|
|
30
|
+
return MandriConfig(
|
|
31
|
+
orchestrator=orchestrator,
|
|
32
|
+
judge=_role(models, "judge"),
|
|
33
|
+
opencode=_role(models, "opencode"),
|
|
34
|
+
codex=_role(models, "codex"),
|
|
35
|
+
claude=_role(models, "claude"),
|
|
36
|
+
user_profile=UserProfileConfig(
|
|
37
|
+
profile_update_interval=_positive_int(
|
|
38
|
+
user_profile,
|
|
39
|
+
"profile_update_interval",
|
|
40
|
+
5,
|
|
41
|
+
),
|
|
42
|
+
user_profile_char_limit=_positive_int(
|
|
43
|
+
user_profile,
|
|
44
|
+
"user_profile_char_limit",
|
|
45
|
+
1375,
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
attachments=AttachmentConfig(
|
|
49
|
+
max_text_file_bytes=_positive_int(
|
|
50
|
+
attachments, "max_text_file_bytes", 128 * 1024
|
|
51
|
+
),
|
|
52
|
+
max_total_text_bytes=_positive_int(
|
|
53
|
+
attachments, "max_total_text_bytes", 256 * 1024
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
agents=AgentConfig(
|
|
57
|
+
max_output_chars=_positive_int(
|
|
58
|
+
agents,
|
|
59
|
+
"max_output_chars",
|
|
60
|
+
AgentConfig().max_output_chars,
|
|
61
|
+
)
|
|
62
|
+
),
|
|
63
|
+
approval=ApprovalConfig(mode=_approval_mode(approval)),
|
|
64
|
+
background=BackgroundConfig(
|
|
65
|
+
max_concurrent=_positive_int(
|
|
66
|
+
background,
|
|
67
|
+
"max_concurrent",
|
|
68
|
+
BackgroundConfig().max_concurrent,
|
|
69
|
+
),
|
|
70
|
+
default_timeout_seconds=_non_negative_int(
|
|
71
|
+
background,
|
|
72
|
+
"default_timeout_seconds",
|
|
73
|
+
BackgroundConfig().default_timeout_seconds,
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
tool_timeouts=[
|
|
77
|
+
ToolTimeoutOverride(
|
|
78
|
+
tool_name=tool_name,
|
|
79
|
+
timeout_seconds=_non_negative_int(
|
|
80
|
+
tool_timeouts,
|
|
81
|
+
tool_name,
|
|
82
|
+
ToolTimeoutOverride().timeout_seconds,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
for tool_name in tool_timeouts
|
|
86
|
+
],
|
|
87
|
+
user_preferences=UserPreferences(
|
|
88
|
+
prompt=_optional_string(user_preferences, "prompt")
|
|
89
|
+
),
|
|
90
|
+
providers=_providers(data),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _providers(data: dict[str, Any]) -> tuple[ProviderConfig, ...]:
|
|
95
|
+
raw = _table(data, "providers")
|
|
96
|
+
providers: list[ProviderConfig] = []
|
|
97
|
+
for provider_id, value in raw.items():
|
|
98
|
+
if not isinstance(value, dict):
|
|
99
|
+
raise ValueError(f"provider config must be a table: {provider_id}")
|
|
100
|
+
kind = _required_string(value, "kind", f"providers.{provider_id}")
|
|
101
|
+
if kind not in {"lmstudio", "openrouter", "opencode", "openai-compatible"}:
|
|
102
|
+
raise ValueError(f"invalid provider kind: {kind}")
|
|
103
|
+
providers.append(
|
|
104
|
+
ProviderConfig(
|
|
105
|
+
id=provider_id,
|
|
106
|
+
kind=cast(ProviderKind, kind),
|
|
107
|
+
name=_optional_string(value, "name"),
|
|
108
|
+
base_url=_optional_string(value, "base_url"),
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return tuple(providers)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _user_profile(data: dict[str, Any]) -> dict[str, Any]:
|
|
115
|
+
if "user_profile" in data:
|
|
116
|
+
return _table(data, "user_profile")
|
|
117
|
+
return _table(data, "memory")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _table(data: dict[str, Any], key: str) -> dict[str, Any]:
|
|
121
|
+
value = data.get(key, {})
|
|
122
|
+
if not isinstance(value, dict):
|
|
123
|
+
raise ValueError(f"config section must be a table: {key}")
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _orchestrator(data: dict[str, Any], models: dict[str, Any]) -> ModelConfig:
|
|
128
|
+
if "orchestrator" in models:
|
|
129
|
+
config = _role(models, "orchestrator")
|
|
130
|
+
if config is None:
|
|
131
|
+
raise ValueError("missing orchestrator config")
|
|
132
|
+
return config
|
|
133
|
+
if "model" in data:
|
|
134
|
+
legacy = _table(data, "model")
|
|
135
|
+
if not legacy:
|
|
136
|
+
return ModelConfig()
|
|
137
|
+
return _model_config(legacy, "model")
|
|
138
|
+
return ModelConfig()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _role(data: dict[str, Any], role: ModelRole) -> ModelConfig | None:
|
|
142
|
+
if role not in data:
|
|
143
|
+
return None
|
|
144
|
+
return _model_config(_table(data, role), role)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _model_config(
|
|
148
|
+
data: dict[str, Any], role: ModelRole | Literal["model"]
|
|
149
|
+
) -> ModelConfig:
|
|
150
|
+
provider = _required_string(data, "provider", role)
|
|
151
|
+
if provider == "native" and role not in {"codex", "claude"}:
|
|
152
|
+
raise ValueError(f"native provider is not supported for role: {role}")
|
|
153
|
+
return ModelConfig(
|
|
154
|
+
provider=provider,
|
|
155
|
+
model=_required_string(data, "model", role),
|
|
156
|
+
reasoning_effort=_optional_string(data, "reasoning_effort"),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _optional_string(data: dict[str, Any], key: str) -> str | None:
|
|
161
|
+
value = data.get(key)
|
|
162
|
+
if value is None:
|
|
163
|
+
return None
|
|
164
|
+
if not isinstance(value, str):
|
|
165
|
+
raise ValueError(f"config value must be a string: {key}")
|
|
166
|
+
return value or None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _required_string(data: dict[str, Any], key: str, section: str) -> str:
|
|
170
|
+
value = _optional_string(data, key)
|
|
171
|
+
if value is None:
|
|
172
|
+
raise ValueError(f"config value is required: {section}.{key}")
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _positive_int(data: dict[str, Any], key: str, default: int) -> int:
|
|
177
|
+
value = data.get(key, default)
|
|
178
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
179
|
+
raise ValueError(f"config value must be a positive integer: {key}")
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _non_negative_int(data: dict[str, Any], key: str, default: int) -> int:
|
|
184
|
+
value = data.get(key, default)
|
|
185
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
186
|
+
raise ValueError(f"config value must be a non-negative integer: {key}")
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _approval_mode(data: dict[str, Any]) -> ApprovalMode:
|
|
191
|
+
value = _optional_string(data, "mode") or ApprovalMode.MANUAL.value
|
|
192
|
+
try:
|
|
193
|
+
return ApprovalMode(value)
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
raise ValueError(f"invalid approval mode: {value}") from exc
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from mandri.ports.config import (
|
|
4
|
+
BackgroundConfig,
|
|
5
|
+
MandriConfig,
|
|
6
|
+
ModelConfig,
|
|
7
|
+
ModelRole,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def to_toml(config: MandriConfig) -> str:
|
|
12
|
+
lines: list[str] = []
|
|
13
|
+
preferences_prompt = config.user_preferences.prompt
|
|
14
|
+
if preferences_prompt is not None:
|
|
15
|
+
value = json.dumps(preferences_prompt, ensure_ascii=False)
|
|
16
|
+
lines.extend(("[user_preferences]", f"prompt = {value}", ""))
|
|
17
|
+
for provider in config.providers:
|
|
18
|
+
lines.extend(
|
|
19
|
+
(
|
|
20
|
+
f"[providers.{json.dumps(provider.id)}]",
|
|
21
|
+
f"kind = {json.dumps(provider.kind)}",
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
if provider.name is not None:
|
|
25
|
+
lines.append(f"name = {json.dumps(provider.name, ensure_ascii=False)}")
|
|
26
|
+
if provider.base_url is not None:
|
|
27
|
+
lines.append(f"base_url = {json.dumps(provider.base_url)}")
|
|
28
|
+
lines.append("")
|
|
29
|
+
for role in ("orchestrator", "judge", "opencode", "codex", "claude"):
|
|
30
|
+
model = config.configured_role(role)
|
|
31
|
+
if model is not None:
|
|
32
|
+
lines.extend(_serialize_role(role, model))
|
|
33
|
+
lines.extend(
|
|
34
|
+
(
|
|
35
|
+
"[user_profile]",
|
|
36
|
+
(
|
|
37
|
+
"profile_update_interval = "
|
|
38
|
+
f"{config.user_profile.profile_update_interval}"
|
|
39
|
+
),
|
|
40
|
+
(
|
|
41
|
+
"user_profile_char_limit = "
|
|
42
|
+
f"{config.user_profile.user_profile_char_limit}"
|
|
43
|
+
),
|
|
44
|
+
"",
|
|
45
|
+
"[attachments]",
|
|
46
|
+
f"max_text_file_bytes = {config.attachments.max_text_file_bytes}",
|
|
47
|
+
f"max_total_text_bytes = {config.attachments.max_total_text_bytes}",
|
|
48
|
+
"",
|
|
49
|
+
"[agents]",
|
|
50
|
+
f"max_output_chars = {config.agents.max_output_chars}",
|
|
51
|
+
"",
|
|
52
|
+
"[approval]",
|
|
53
|
+
f"mode = {json.dumps(config.approval.mode.value)}",
|
|
54
|
+
"",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
if config.background != BackgroundConfig():
|
|
58
|
+
lines.extend(
|
|
59
|
+
(
|
|
60
|
+
"[background]",
|
|
61
|
+
f"max_concurrent = {config.background.max_concurrent}",
|
|
62
|
+
(
|
|
63
|
+
"default_timeout_seconds = "
|
|
64
|
+
f"{config.background.default_timeout_seconds}"
|
|
65
|
+
),
|
|
66
|
+
"",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
if config.tool_timeouts:
|
|
70
|
+
lines.append("[tool_timeouts]")
|
|
71
|
+
lines.extend(
|
|
72
|
+
f"{json.dumps(override.tool_name)} = {override.timeout_seconds}"
|
|
73
|
+
for override in config.tool_timeouts
|
|
74
|
+
)
|
|
75
|
+
lines.append("")
|
|
76
|
+
return "\n".join(lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _serialize_role(role: ModelRole, config: ModelConfig) -> list[str]:
|
|
80
|
+
if config.provider is None:
|
|
81
|
+
raise ValueError(f"config value is required: {role}.provider")
|
|
82
|
+
if config.model is None:
|
|
83
|
+
raise ValueError(f"config value is required: {role}.model")
|
|
84
|
+
if config.provider == "native" and role not in {"codex", "claude"}:
|
|
85
|
+
raise ValueError(f"native provider is not supported for role: {role}")
|
|
86
|
+
lines = [
|
|
87
|
+
f"[models.{role}]",
|
|
88
|
+
f"provider = {json.dumps(config.provider, ensure_ascii=False)}",
|
|
89
|
+
f"model = {json.dumps(config.model, ensure_ascii=False)}",
|
|
90
|
+
]
|
|
91
|
+
if config.reasoning_effort is not None:
|
|
92
|
+
value = json.dumps(config.reasoning_effort, ensure_ascii=False)
|
|
93
|
+
lines.append(f"reasoning_effort = {value}")
|
|
94
|
+
lines.append("")
|
|
95
|
+
return lines
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mandri.adapters.filesystem import atomic_write_text
|
|
7
|
+
from mandri.ports.config import (
|
|
8
|
+
MandriConfig,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from .decode import (
|
|
12
|
+
from_toml,
|
|
13
|
+
)
|
|
14
|
+
from .encode import (
|
|
15
|
+
to_toml,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TomlConfig:
|
|
20
|
+
def __init__(self, path: Path) -> None:
|
|
21
|
+
self._path = path
|
|
22
|
+
self._lock = asyncio.Lock()
|
|
23
|
+
|
|
24
|
+
async def load(self) -> MandriConfig:
|
|
25
|
+
if not await asyncio.to_thread(self._path.is_file):
|
|
26
|
+
return MandriConfig()
|
|
27
|
+
data = await asyncio.to_thread(self._read)
|
|
28
|
+
return from_toml(data)
|
|
29
|
+
|
|
30
|
+
async def save(self, config: MandriConfig) -> None:
|
|
31
|
+
content = to_toml(config)
|
|
32
|
+
async with self._lock:
|
|
33
|
+
await asyncio.to_thread(atomic_write_text, self._path, content)
|
|
34
|
+
|
|
35
|
+
def _read(self) -> dict[str, Any]:
|
|
36
|
+
with self._path.open("rb") as handle:
|
|
37
|
+
return tomllib.load(handle)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
if os.name == "nt":
|
|
6
|
+
from .windows_acl import restrict_windows_file, windows_file_is_private
|
|
7
|
+
else:
|
|
8
|
+
|
|
9
|
+
def restrict_windows_file(path: Path) -> None:
|
|
10
|
+
raise OSError(f"Windows ACLs are unavailable for {path}")
|
|
11
|
+
|
|
12
|
+
def windows_file_is_private(path: Path) -> bool:
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def restrict_credentials_file(path: Path) -> None:
|
|
17
|
+
if os.name == "nt":
|
|
18
|
+
restrict_windows_file(path)
|
|
19
|
+
return
|
|
20
|
+
os.chmod(path, 0o600)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def credentials_file_is_private(path: Path) -> bool:
|
|
24
|
+
if os.name == "nt":
|
|
25
|
+
return windows_file_is_private(path)
|
|
26
|
+
return stat.S_IMODE(path.stat().st_mode) == 0o600
|