maple-mcp 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.
- maple/__init__.py +3 -0
- maple/attach.py +128 -0
- maple/auth.py +63 -0
- maple/cli.py +220 -0
- maple/config.py +189 -0
- maple/hooks.py +37 -0
- maple/middleware.py +42 -0
- maple/models.py +40 -0
- maple/operator/__init__.py +1 -0
- maple/operator/agent.py +292 -0
- maple/operator/prompt.md +48 -0
- maple/operator/server.py +511 -0
- maple/overseer/__init__.py +1 -0
- maple/overseer/agent.py +213 -0
- maple/overseer/prompt.md +56 -0
- maple/overseer/server.py +429 -0
- maple/services.py +229 -0
- maple/sessions.py +184 -0
- maple/tui.py +273 -0
- maple/vision.py +58 -0
- maple_mcp-0.1.0.dist-info/METADATA +162 -0
- maple_mcp-0.1.0.dist-info/RECORD +25 -0
- maple_mcp-0.1.0.dist-info/WHEEL +4 -0
- maple_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- maple_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
maple/__init__.py
ADDED
maple/attach.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""MAPLE Attach TUI — tail live logs from running services."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.containers import Grid
|
|
7
|
+
from textual.widgets import RichLog, Static
|
|
8
|
+
|
|
9
|
+
LOG_DIR = Path.home() / ".maple" / "logs"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServiceLogPane(Static):
|
|
13
|
+
"""A single service's log pane with status header and scrollable log."""
|
|
14
|
+
|
|
15
|
+
DEFAULT_CSS = """
|
|
16
|
+
ServiceLogPane {
|
|
17
|
+
border: solid gray;
|
|
18
|
+
height: 1fr;
|
|
19
|
+
}
|
|
20
|
+
ServiceLogPane Static {
|
|
21
|
+
height: 1;
|
|
22
|
+
background: $panel;
|
|
23
|
+
padding: 0 1;
|
|
24
|
+
}
|
|
25
|
+
ServiceLogPane RichLog {
|
|
26
|
+
height: 1fr;
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, service_name: str, **kwargs):
|
|
31
|
+
super().__init__(**kwargs)
|
|
32
|
+
self.service_name = service_name
|
|
33
|
+
|
|
34
|
+
def compose(self) -> ComposeResult:
|
|
35
|
+
yield Static(f"[green]● {self.service_name}[/]", id=f"status-{self.service_name}")
|
|
36
|
+
yield RichLog(id=f"log-{self.service_name}", highlight=True, markup=False, wrap=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MapleAttachApp(App):
|
|
40
|
+
"""TUI for tailing MAPLE service logs in split panes."""
|
|
41
|
+
|
|
42
|
+
TITLE = "MAPLE — Service Logs"
|
|
43
|
+
CSS = """
|
|
44
|
+
Grid { grid-size: 2; height: 1fr; }
|
|
45
|
+
"""
|
|
46
|
+
BINDINGS = [("q", "quit", "Quit"), ("ctrl+c", "quit", "Quit")]
|
|
47
|
+
|
|
48
|
+
def __init__(self, services: list[str] = None, **kwargs):
|
|
49
|
+
super().__init__(**kwargs)
|
|
50
|
+
self._services = services or self._discover_services()
|
|
51
|
+
|
|
52
|
+
def _discover_services(self) -> list[str]:
|
|
53
|
+
"""Find services that are currently running (have active PIDs)."""
|
|
54
|
+
pid_dir = Path.home() / ".maple" / "pids"
|
|
55
|
+
if not pid_dir.exists():
|
|
56
|
+
return []
|
|
57
|
+
return [f.stem for f in sorted(pid_dir.glob("*.pid"))]
|
|
58
|
+
|
|
59
|
+
def compose(self) -> ComposeResult:
|
|
60
|
+
if not self._services:
|
|
61
|
+
yield Static("[dim]No services found. Run 'maple serve' first.[/]")
|
|
62
|
+
return
|
|
63
|
+
with Grid():
|
|
64
|
+
for name in self._services:
|
|
65
|
+
yield ServiceLogPane(name, id=f"pane-{name}")
|
|
66
|
+
|
|
67
|
+
async def on_mount(self) -> None:
|
|
68
|
+
self._offsets: dict[str, int] = {}
|
|
69
|
+
for name in self._services:
|
|
70
|
+
path = LOG_DIR / f"{name}.log"
|
|
71
|
+
path.touch(exist_ok=True)
|
|
72
|
+
self._offsets[name] = path.stat().st_size
|
|
73
|
+
self._show_tail(path, self.query_one(f"#log-{name}", RichLog), lines=20)
|
|
74
|
+
# Single watcher on the logs directory
|
|
75
|
+
self.run_worker(self._watch_logs(), exclusive=True)
|
|
76
|
+
|
|
77
|
+
async def _watch_logs(self) -> None:
|
|
78
|
+
"""Poll log files for changes every 500ms. Simple and reliable."""
|
|
79
|
+
import asyncio
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
while True:
|
|
83
|
+
await asyncio.sleep(0.5)
|
|
84
|
+
for name in self._services:
|
|
85
|
+
path = LOG_DIR / f"{name}.log"
|
|
86
|
+
if not path.exists():
|
|
87
|
+
continue
|
|
88
|
+
current_size = path.stat().st_size
|
|
89
|
+
if current_size != self._offsets.get(name, 0):
|
|
90
|
+
log = self.query_one(f"#log-{name}", RichLog)
|
|
91
|
+
self._offsets[name] = self._flush_new_lines(
|
|
92
|
+
path, log, self._offsets[name]
|
|
93
|
+
)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def _show_tail(self, path: Path, log: RichLog, lines: int = 20):
|
|
98
|
+
"""Show the last N lines of the file on attach."""
|
|
99
|
+
try:
|
|
100
|
+
content = path.read_text(errors="replace")
|
|
101
|
+
recent = content.splitlines()[-lines:]
|
|
102
|
+
for line in recent:
|
|
103
|
+
log.write(line)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
def _flush_new_lines(self, path: Path, log: RichLog, offset: int) -> int:
|
|
108
|
+
"""Read new content since last offset and write to log."""
|
|
109
|
+
try:
|
|
110
|
+
size = path.stat().st_size
|
|
111
|
+
if size < offset:
|
|
112
|
+
# File was truncated/rotated
|
|
113
|
+
offset = 0
|
|
114
|
+
with open(path, "r", errors="replace") as f:
|
|
115
|
+
f.seek(offset)
|
|
116
|
+
new_data = f.read()
|
|
117
|
+
new_offset = f.tell()
|
|
118
|
+
for line in new_data.splitlines():
|
|
119
|
+
log.write(line)
|
|
120
|
+
return new_offset
|
|
121
|
+
except Exception:
|
|
122
|
+
return offset
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run_attach(services: list[str] = None):
|
|
126
|
+
"""Launch the attach TUI."""
|
|
127
|
+
app = MapleAttachApp(services=services)
|
|
128
|
+
app.run()
|
maple/auth.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""MAPLE authentication — auto-generated device tokens for session identity."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CREDENTIALS_FILE = Path.home() / ".maple" / "credentials"
|
|
10
|
+
_UUID_PATTERN = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_or_create_token() -> str:
|
|
14
|
+
"""Get the device token, creating one on first run.
|
|
15
|
+
|
|
16
|
+
Tokens are UUIDv4 strings stored in ~/.maple/credentials.
|
|
17
|
+
Used as bearer tokens for MCP server auth, keying session state.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The device's unique token string.
|
|
21
|
+
"""
|
|
22
|
+
if CREDENTIALS_FILE.exists():
|
|
23
|
+
token = CREDENTIALS_FILE.read_text().strip()
|
|
24
|
+
if token and is_valid_token(token):
|
|
25
|
+
return token
|
|
26
|
+
|
|
27
|
+
# Generate new token
|
|
28
|
+
token = str(uuid.uuid4())
|
|
29
|
+
CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
# Atomic write: write to temp file then rename
|
|
32
|
+
import tempfile
|
|
33
|
+
fd, tmp_path = tempfile.mkstemp(dir=CREDENTIALS_FILE.parent, prefix=".maple_cred_")
|
|
34
|
+
try:
|
|
35
|
+
os.write(fd, token.encode())
|
|
36
|
+
os.fchmod(fd, 0o600)
|
|
37
|
+
os.close(fd)
|
|
38
|
+
os.replace(tmp_path, CREDENTIALS_FILE)
|
|
39
|
+
except Exception:
|
|
40
|
+
try:
|
|
41
|
+
os.close(fd)
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
try:
|
|
45
|
+
os.unlink(tmp_path)
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
# Fallback: direct write
|
|
49
|
+
CREDENTIALS_FILE.write_text(token)
|
|
50
|
+
try:
|
|
51
|
+
os.chmod(CREDENTIALS_FILE, 0o600)
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
return token
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_valid_token(token: str) -> bool:
|
|
59
|
+
"""Validate that a token is a well-formed UUIDv4.
|
|
60
|
+
|
|
61
|
+
Prevents arbitrary/malformed strings from becoming dict keys.
|
|
62
|
+
"""
|
|
63
|
+
return bool(_UUID_PATTERN.match(token.lower()))
|
maple/cli.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""MAPLE CLI — Command-line interface for serving and chatting with agents."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(
|
|
7
|
+
name="maple",
|
|
8
|
+
help="MAPLE — Model-Agnostic Platform for Laboratory Experiments",
|
|
9
|
+
no_args_is_help=True,
|
|
10
|
+
)
|
|
11
|
+
serve_app = typer.Typer(help="Start MAPLE services in the background.")
|
|
12
|
+
chat_app = typer.Typer(help="Chat with a MAPLE agent via TUI.")
|
|
13
|
+
app.add_typer(serve_app, name="serve")
|
|
14
|
+
app.add_typer(chat_app, name="chat")
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# maple serve <subcommand>
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@serve_app.command("all")
|
|
25
|
+
def serve_all(
|
|
26
|
+
dev: bool = typer.Option(False, "--dev", help="Enable auto-reload on file changes"),
|
|
27
|
+
config: str = typer.Option("maple.config.yaml", help="Path to config file"),
|
|
28
|
+
):
|
|
29
|
+
"""Start all MCP servers and agents (Operator + Overseer)."""
|
|
30
|
+
_do_serve(agent=None, stub=False, dev=dev, config=config)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@serve_app.command("operator")
|
|
34
|
+
def serve_operator(
|
|
35
|
+
dev: bool = typer.Option(False, "--dev", help="Enable auto-reload on file changes"),
|
|
36
|
+
config: str = typer.Option("maple.config.yaml", help="Path to config file"),
|
|
37
|
+
):
|
|
38
|
+
"""Start Operator MCP server and agent."""
|
|
39
|
+
_do_serve(agent="operator", stub=False, dev=dev, config=config)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@serve_app.command("overseer")
|
|
43
|
+
def serve_overseer(
|
|
44
|
+
dev: bool = typer.Option(False, "--dev", help="Enable auto-reload on file changes"),
|
|
45
|
+
config: str = typer.Option("maple.config.yaml", help="Path to config file"),
|
|
46
|
+
):
|
|
47
|
+
"""Start Overseer MCP server and agent."""
|
|
48
|
+
_do_serve(agent="overseer", stub=False, dev=dev, config=config)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@serve_app.command("stub")
|
|
52
|
+
def serve_stub(
|
|
53
|
+
dev: bool = typer.Option(False, "--dev", help="Enable auto-reload on file changes"),
|
|
54
|
+
config: str = typer.Option("maple.config.yaml", help="Path to config file"),
|
|
55
|
+
):
|
|
56
|
+
"""Start full demo stack (stub node + Operator MCP + mock agent)."""
|
|
57
|
+
_do_serve(agent=None, stub=True, dev=dev, config=config)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@serve_app.command("mock")
|
|
61
|
+
def serve_mock(
|
|
62
|
+
dev: bool = typer.Option(False, "--dev", help="Enable auto-reload on file changes"),
|
|
63
|
+
):
|
|
64
|
+
"""Start mock agent only (for demo/testing)."""
|
|
65
|
+
_do_serve(agent="mock", stub=False, dev=dev, config="maple.config.yaml")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _do_serve(agent: str = None, stub: bool = False, dev: bool = False, config: str = "maple.config.yaml"):
|
|
69
|
+
"""Shared serve logic."""
|
|
70
|
+
from dotenv import load_dotenv
|
|
71
|
+
load_dotenv(override=True)
|
|
72
|
+
|
|
73
|
+
from maple.services import start_services
|
|
74
|
+
|
|
75
|
+
started = start_services(agent=agent, config=config, stub=stub, dev=dev)
|
|
76
|
+
|
|
77
|
+
if not started:
|
|
78
|
+
console.print("[red]No services to start.[/red]")
|
|
79
|
+
raise typer.Exit(1)
|
|
80
|
+
|
|
81
|
+
console.print()
|
|
82
|
+
for name, port, pid in started:
|
|
83
|
+
console.print(f" [green]✓[/green] {name:20s} :{port} (pid {pid})")
|
|
84
|
+
console.print()
|
|
85
|
+
if dev:
|
|
86
|
+
console.print("[yellow]Dev mode: services will auto-reload on file changes[/yellow]")
|
|
87
|
+
console.print("[dim]Logs: ~/.maple/logs/[/dim]")
|
|
88
|
+
console.print("[dim]Stop: maple down[/dim]")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# maple chat <subcommand>
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@chat_app.command("operator")
|
|
97
|
+
def chat_operator(
|
|
98
|
+
host: str = typer.Option("localhost", help="Agent service host"),
|
|
99
|
+
resume: bool = typer.Option(False, "--resume", help="Resume most recent operator session"),
|
|
100
|
+
):
|
|
101
|
+
"""Chat with the Operator agent."""
|
|
102
|
+
from dotenv import load_dotenv
|
|
103
|
+
load_dotenv(override=True)
|
|
104
|
+
from maple.tui import run_chat
|
|
105
|
+
session_id = _get_session_id("operator", resume)
|
|
106
|
+
run_chat(agent="operator", host=host, session_id=session_id)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@chat_app.command("overseer")
|
|
110
|
+
def chat_overseer(
|
|
111
|
+
host: str = typer.Option("localhost", help="Agent service host"),
|
|
112
|
+
resume: bool = typer.Option(False, "--resume", help="Resume most recent overseer session"),
|
|
113
|
+
):
|
|
114
|
+
"""Chat with the Overseer agent."""
|
|
115
|
+
from dotenv import load_dotenv
|
|
116
|
+
load_dotenv(override=True)
|
|
117
|
+
from maple.tui import run_chat
|
|
118
|
+
session_id = _get_session_id("overseer", resume)
|
|
119
|
+
run_chat(agent="overseer", host=host, session_id=session_id)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_session_id(agent: str, resume: bool) -> str:
|
|
123
|
+
"""Get or create a session ID for the chat.
|
|
124
|
+
|
|
125
|
+
If resume=True, finds the most recent session file for the agent.
|
|
126
|
+
Otherwise generates a new UUID.
|
|
127
|
+
"""
|
|
128
|
+
import uuid
|
|
129
|
+
from pathlib import Path
|
|
130
|
+
|
|
131
|
+
sessions_dir = Path.home() / ".maple" / "sessions" / agent
|
|
132
|
+
|
|
133
|
+
if resume and sessions_dir.exists():
|
|
134
|
+
session_dirs = [d for d in sessions_dir.iterdir() if d.is_dir() and d.name.startswith("session_")]
|
|
135
|
+
if session_dirs:
|
|
136
|
+
latest = max(session_dirs, key=lambda d: d.stat().st_mtime)
|
|
137
|
+
# Extract UUID from "session_{uuid}" directory name
|
|
138
|
+
session_id = latest.name.removeprefix("session_")
|
|
139
|
+
console.print(f"[dim]Resuming session: {session_id[:8]}...[/dim]")
|
|
140
|
+
return session_id
|
|
141
|
+
else:
|
|
142
|
+
console.print("[dim]No previous session found. Starting new.[/dim]")
|
|
143
|
+
|
|
144
|
+
return str(uuid.uuid4())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# maple down
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command()
|
|
153
|
+
def down():
|
|
154
|
+
"""Stop all running MAPLE services."""
|
|
155
|
+
from maple.services import stop_services
|
|
156
|
+
|
|
157
|
+
stopped = stop_services()
|
|
158
|
+
|
|
159
|
+
if stopped:
|
|
160
|
+
console.print(f"[green]✓[/green] Stopped {len(stopped)} service(s): {', '.join(stopped)}")
|
|
161
|
+
else:
|
|
162
|
+
console.print("[dim]No running services found.[/dim]")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# maple logs
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def logs():
|
|
172
|
+
"""View live service logs in a TUI (tail mode)."""
|
|
173
|
+
from maple.attach import run_attach
|
|
174
|
+
run_attach()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# maple status
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command()
|
|
183
|
+
def status():
|
|
184
|
+
"""Check status of running MAPLE services."""
|
|
185
|
+
import httpx
|
|
186
|
+
from maple.services import get_status
|
|
187
|
+
|
|
188
|
+
running = get_status()
|
|
189
|
+
|
|
190
|
+
if not running:
|
|
191
|
+
console.print("[dim]No MAPLE services registered. Run 'maple serve' first.[/dim]")
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
console.print()
|
|
195
|
+
for name, pid, is_running in running:
|
|
196
|
+
if is_running:
|
|
197
|
+
console.print(f" [green]✓[/green] {name:20s} (pid {pid})")
|
|
198
|
+
else:
|
|
199
|
+
console.print(f" [red]✗[/red] {name:20s} (pid {pid} — dead)")
|
|
200
|
+
console.print()
|
|
201
|
+
|
|
202
|
+
# Also try pinging known endpoints
|
|
203
|
+
endpoints = {
|
|
204
|
+
"operator-mcp": "http://localhost:8102/ping",
|
|
205
|
+
"overseer-mcp": "http://localhost:8103/ping",
|
|
206
|
+
"operator-agent": "http://localhost:8202/ping",
|
|
207
|
+
"overseer-agent": "http://localhost:8203/ping",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for name, url in endpoints.items():
|
|
211
|
+
try:
|
|
212
|
+
resp = httpx.get(url, timeout=2)
|
|
213
|
+
if resp.status_code == 200:
|
|
214
|
+
console.print(f" [green]●[/green] {name} responding")
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
app()
|
maple/config.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Configuration system for MAPLE."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Models
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PostActionHook(BaseModel):
|
|
18
|
+
"""A MADSci node action to call after each operator action."""
|
|
19
|
+
|
|
20
|
+
node: str
|
|
21
|
+
action: str
|
|
22
|
+
args: dict = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExperimentConfig(BaseModel):
|
|
26
|
+
"""Experiment brief template."""
|
|
27
|
+
|
|
28
|
+
name: str = "Untitled Experiment"
|
|
29
|
+
objective: str = ""
|
|
30
|
+
constraints: list[str] = []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OperatorConfig(BaseModel):
|
|
34
|
+
"""Operator agent configuration."""
|
|
35
|
+
|
|
36
|
+
vision_backend: str = "" # "module:ClassName" or empty for StubBackend
|
|
37
|
+
custom_tools: list[str] = [] # ["module:function_name", ...] to register at startup
|
|
38
|
+
post_action_hooks: list[PostActionHook] = []
|
|
39
|
+
prompt: str = "" # Path to custom prompt file, or empty for default
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OverseerConfig(BaseModel):
|
|
43
|
+
"""Overseer agent configuration."""
|
|
44
|
+
|
|
45
|
+
custom_tools: list[str] = [] # ["module:function_name", ...] to register at startup
|
|
46
|
+
prompt: str = "" # Path to custom prompt file, or empty for default
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MapleConfig(BaseModel):
|
|
50
|
+
"""Root MAPLE configuration."""
|
|
51
|
+
|
|
52
|
+
experiment: ExperimentConfig = ExperimentConfig()
|
|
53
|
+
operator: OperatorConfig = OperatorConfig()
|
|
54
|
+
overseer: OverseerConfig = OverseerConfig()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Loader
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_config(path: Optional[Path] = None) -> MapleConfig:
|
|
63
|
+
"""Load MAPLE configuration from a YAML file.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: Path to config file. Defaults to maple.config.yaml in current directory.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Validated MapleConfig instance.
|
|
70
|
+
"""
|
|
71
|
+
path = path or Path("maple.config.yaml")
|
|
72
|
+
if not path.exists():
|
|
73
|
+
return MapleConfig()
|
|
74
|
+
data = yaml.safe_load(path.read_text())
|
|
75
|
+
return MapleConfig(**(data or {}))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Dynamic Import
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def import_string(dotted_path: str):
|
|
84
|
+
"""Import a class or object from a 'module:ClassName' string.
|
|
85
|
+
|
|
86
|
+
Supports both colon-separated ('vision:BlockSortingVision') and
|
|
87
|
+
dot-separated ('vision.BlockSortingVision') formats.
|
|
88
|
+
|
|
89
|
+
If the module is a relative file path (e.g., 'vision' resolving to
|
|
90
|
+
'./vision.py'), the current directory is added to sys.path temporarily.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
dotted_path: Import path in 'module:Class' or 'module.Class' format.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The imported class or object.
|
|
97
|
+
"""
|
|
98
|
+
if ":" in dotted_path:
|
|
99
|
+
module_path, class_name = dotted_path.rsplit(":", 1)
|
|
100
|
+
else:
|
|
101
|
+
module_path, _, class_name = dotted_path.rpartition(".")
|
|
102
|
+
|
|
103
|
+
if not module_path or not class_name:
|
|
104
|
+
raise ImportError(f"Invalid import path: '{dotted_path}'. Use 'module:ClassName' format.")
|
|
105
|
+
|
|
106
|
+
# Support relative imports from CWD
|
|
107
|
+
cwd = str(Path.cwd())
|
|
108
|
+
if cwd not in sys.path:
|
|
109
|
+
sys.path.insert(0, cwd)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
module = importlib.import_module(module_path)
|
|
113
|
+
except ModuleNotFoundError as e:
|
|
114
|
+
raise ImportError(f"Cannot import module '{module_path}' from '{dotted_path}'") from e
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
return getattr(module, class_name)
|
|
118
|
+
except AttributeError as e:
|
|
119
|
+
raise ImportError(f"Module '{module_path}' has no attribute '{class_name}'") from e
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_vision_backend(config: MapleConfig):
|
|
123
|
+
"""Load the VisionBackend class from config and instantiate it.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
config: Loaded MapleConfig.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
A VisionBackend instance. StubBackend if no backend specified.
|
|
130
|
+
"""
|
|
131
|
+
from maple.vision import StubBackend
|
|
132
|
+
|
|
133
|
+
backend_path = config.operator.vision_backend
|
|
134
|
+
if not backend_path:
|
|
135
|
+
return StubBackend()
|
|
136
|
+
|
|
137
|
+
cls = import_string(backend_path)
|
|
138
|
+
return cls()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_custom_tools(config: MapleConfig):
|
|
142
|
+
"""Load and register custom MCP tools from config.
|
|
143
|
+
|
|
144
|
+
Each entry in config.operator.custom_tools should be a "module:function"
|
|
145
|
+
string. The function must already be decorated with @mcp.tool, OR it will
|
|
146
|
+
be registered programmatically.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
config: Loaded MapleConfig.
|
|
150
|
+
"""
|
|
151
|
+
if not config.operator.custom_tools:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
from maple.operator.server import mcp
|
|
155
|
+
|
|
156
|
+
for tool_path in config.operator.custom_tools:
|
|
157
|
+
try:
|
|
158
|
+
func = import_string(tool_path)
|
|
159
|
+
# If not already registered, register it
|
|
160
|
+
if not hasattr(func, "__mcp_tool__"):
|
|
161
|
+
mcp.tool(func)
|
|
162
|
+
except ImportError as e:
|
|
163
|
+
raise ImportError(
|
|
164
|
+
f"Failed to load custom tool '{tool_path}': {e}. "
|
|
165
|
+
f"Check the path in maple.config.yaml under operator.custom_tools."
|
|
166
|
+
) from e
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def load_overseer_custom_tools(config: MapleConfig):
|
|
170
|
+
"""Load and register custom MCP tools for the Overseer.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
config: Loaded MapleConfig.
|
|
174
|
+
"""
|
|
175
|
+
if not config.overseer.custom_tools:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
from maple.overseer.server import mcp
|
|
179
|
+
|
|
180
|
+
for tool_path in config.overseer.custom_tools:
|
|
181
|
+
try:
|
|
182
|
+
func = import_string(tool_path)
|
|
183
|
+
if not hasattr(func, "__mcp_tool__"):
|
|
184
|
+
mcp.tool(func)
|
|
185
|
+
except ImportError as e:
|
|
186
|
+
raise ImportError(
|
|
187
|
+
f"Failed to load custom tool '{tool_path}': {e}. "
|
|
188
|
+
f"Check the path in maple.config.yaml under overseer.custom_tools."
|
|
189
|
+
) from e
|
maple/hooks.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Agent event hooks for MAPLE provenance capture."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ReasoningCaptureHook:
|
|
8
|
+
"""Captures LLM reasoning text and logs to MADSci Event Manager.
|
|
9
|
+
|
|
10
|
+
Intercepts text tokens between tool calls, buffers them,
|
|
11
|
+
and flushes to the Event Manager when a tool is invoked.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, mcp_base_url: str = None):
|
|
15
|
+
self.mcp_base_url = mcp_base_url or os.getenv("MCP_EXECUTOR_URL", "http://localhost:8102")
|
|
16
|
+
self._buffer = ""
|
|
17
|
+
|
|
18
|
+
def accumulate(self, text: str):
|
|
19
|
+
"""Add text to the reasoning buffer."""
|
|
20
|
+
self._buffer += text
|
|
21
|
+
|
|
22
|
+
def flush(self, tool_name: str):
|
|
23
|
+
"""Flush buffered reasoning to the MCP server's log endpoint."""
|
|
24
|
+
if not self._buffer.strip():
|
|
25
|
+
return
|
|
26
|
+
try:
|
|
27
|
+
requests.post(
|
|
28
|
+
f"{self.mcp_base_url}/log_reasoning",
|
|
29
|
+
json={
|
|
30
|
+
"reasoning": self._buffer.strip(),
|
|
31
|
+
"before_tool": tool_name,
|
|
32
|
+
},
|
|
33
|
+
timeout=5,
|
|
34
|
+
)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
self._buffer = ""
|
maple/middleware.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""MAPLE MCP middleware — extracts identity from Authorization header."""
|
|
2
|
+
|
|
3
|
+
from fastmcp.server.dependencies import get_http_headers
|
|
4
|
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
5
|
+
from fastmcp.exceptions import ToolError
|
|
6
|
+
|
|
7
|
+
from maple.auth import is_valid_token
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthMiddleware(Middleware):
|
|
11
|
+
"""Extracts bearer token from Authorization header and stashes in context.
|
|
12
|
+
|
|
13
|
+
Every tool call passes through this middleware. The token is made
|
|
14
|
+
available to tools via `await ctx.get_state("maple_identity")`.
|
|
15
|
+
|
|
16
|
+
Rejects requests with missing or malformed tokens.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
|
20
|
+
headers = get_http_headers(include={"authorization"})
|
|
21
|
+
authorization = headers.get("authorization", "")
|
|
22
|
+
|
|
23
|
+
scheme, _, token = authorization.partition(" ")
|
|
24
|
+
token = token.strip()
|
|
25
|
+
|
|
26
|
+
if scheme.lower() != "bearer" or not token:
|
|
27
|
+
raise ToolError(
|
|
28
|
+
"Authorization required. Include 'Authorization: Bearer <token>' header. "
|
|
29
|
+
"Run 'maple chat' to auto-generate credentials."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if not is_valid_token(token):
|
|
33
|
+
raise ToolError(
|
|
34
|
+
"Invalid token format. Expected a UUIDv4 string. "
|
|
35
|
+
"Delete ~/.maple/credentials and retry to regenerate."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if context.fastmcp_context is None:
|
|
39
|
+
raise ToolError("Request context unavailable")
|
|
40
|
+
|
|
41
|
+
await context.fastmcp_context.set_state("maple_identity", token)
|
|
42
|
+
return await call_next(context)
|