monkeyscode 1.0.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.
- monkeyscode/__init__.py +178 -0
- monkeyscode/_http.py +83 -0
- monkeyscode/admin.py +141 -0
- monkeyscode/agent.py +772 -0
- monkeyscode/ci.py +288 -0
- monkeyscode/cli_process.py +730 -0
- monkeyscode/daemon.py +201 -0
- monkeyscode/events.py +323 -0
- monkeyscode/export.py +319 -0
- monkeyscode/hooks.py +190 -0
- monkeyscode/mcp.py +283 -0
- monkeyscode/orchestrator.py +170 -0
- monkeyscode/otel.py +181 -0
- monkeyscode/py.typed +1 -0
- monkeyscode/runs.py +138 -0
- monkeyscode/sandbox.py +125 -0
- monkeyscode/session.py +227 -0
- monkeyscode/subagent.py +195 -0
- monkeyscode/telemetry.py +204 -0
- monkeyscode/tools.py +133 -0
- monkeyscode/watcher.py +137 -0
- monkeyscode-1.0.0.dist-info/METADATA +152 -0
- monkeyscode-1.0.0.dist-info/RECORD +25 -0
- monkeyscode-1.0.0.dist-info/WHEEL +4 -0
- monkeyscode-1.0.0.dist-info/licenses/LICENSE +21 -0
monkeyscode/runs.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonkeysCode SDK — Run Persistence.
|
|
3
|
+
|
|
4
|
+
Save, load, list, and delete agent run records.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from monkeyscode.runs import RunStore
|
|
9
|
+
|
|
10
|
+
store = RunStore()
|
|
11
|
+
record = store.save_result("fix errors", result)
|
|
12
|
+
print(record.id)
|
|
13
|
+
|
|
14
|
+
loaded = store.load(record.id)
|
|
15
|
+
all_runs = store.list(limit=10)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import asdict, dataclass, field
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from monkeyscode.events import AgentResult
|
|
28
|
+
|
|
29
|
+
# ── Types ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class RunRecord:
|
|
34
|
+
"""A persisted run record."""
|
|
35
|
+
|
|
36
|
+
id: str = ""
|
|
37
|
+
prompt: str = ""
|
|
38
|
+
model: str = ""
|
|
39
|
+
session_id: str = ""
|
|
40
|
+
status: str = ""
|
|
41
|
+
summary: str = ""
|
|
42
|
+
cost: float = 0.0
|
|
43
|
+
tokens: dict[str, int] = field(default_factory=dict)
|
|
44
|
+
duration_ms: float = 0
|
|
45
|
+
created_at: str = ""
|
|
46
|
+
completed_at: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Store ────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RunStore:
|
|
53
|
+
"""
|
|
54
|
+
File-based run persistence.
|
|
55
|
+
|
|
56
|
+
Stores runs as JSON files in a directory (default: ~/.monkeyscode/runs).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, directory: str | None = None) -> None:
|
|
60
|
+
if directory is None:
|
|
61
|
+
directory = os.path.join(Path.home(), ".monkeyscode", "runs")
|
|
62
|
+
self._dir = directory
|
|
63
|
+
os.makedirs(self._dir, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def directory(self) -> str:
|
|
67
|
+
return self._dir
|
|
68
|
+
|
|
69
|
+
def save(self, run: RunRecord) -> None:
|
|
70
|
+
"""Save a run record to disk."""
|
|
71
|
+
path = os.path.join(self._dir, f"{run.id}.json")
|
|
72
|
+
with open(path, "w") as f:
|
|
73
|
+
json.dump(asdict(run), f, indent=2)
|
|
74
|
+
|
|
75
|
+
def load(self, id_or_prefix: str) -> RunRecord:
|
|
76
|
+
"""Load a run by full or prefix ID."""
|
|
77
|
+
# Try exact match
|
|
78
|
+
path = os.path.join(self._dir, f"{id_or_prefix}.json")
|
|
79
|
+
if os.path.exists(path):
|
|
80
|
+
return self._read_file(path)
|
|
81
|
+
|
|
82
|
+
# Try prefix match
|
|
83
|
+
for entry in os.listdir(self._dir):
|
|
84
|
+
if entry.startswith(id_or_prefix) and entry.endswith(".json"):
|
|
85
|
+
return self._read_file(os.path.join(self._dir, entry))
|
|
86
|
+
|
|
87
|
+
raise FileNotFoundError(f"Run not found: {id_or_prefix}")
|
|
88
|
+
|
|
89
|
+
def list(self, limit: int = 50) -> list[RunRecord]:
|
|
90
|
+
"""List recent runs, sorted by creation time (newest first)."""
|
|
91
|
+
records: list[RunRecord] = []
|
|
92
|
+
|
|
93
|
+
if not os.path.exists(self._dir):
|
|
94
|
+
return records
|
|
95
|
+
|
|
96
|
+
for entry in os.listdir(self._dir):
|
|
97
|
+
if not entry.endswith(".json"):
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
records.append(self._read_file(os.path.join(self._dir, entry)))
|
|
101
|
+
except Exception:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
records.sort(key=lambda r: r.created_at, reverse=True)
|
|
105
|
+
return records[:limit]
|
|
106
|
+
|
|
107
|
+
def delete(self, run_id: str) -> None:
|
|
108
|
+
"""Delete a run record."""
|
|
109
|
+
path = os.path.join(self._dir, f"{run_id}.json")
|
|
110
|
+
os.remove(path)
|
|
111
|
+
|
|
112
|
+
def save_result(self, prompt: str, result: AgentResult) -> RunRecord:
|
|
113
|
+
"""Create and save a RunRecord from an AgentResult."""
|
|
114
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
115
|
+
record = RunRecord(
|
|
116
|
+
id=f"run_{int(time.time() * 1000)}",
|
|
117
|
+
prompt=prompt,
|
|
118
|
+
model=result.model or "",
|
|
119
|
+
session_id=result.session_id or "",
|
|
120
|
+
status=result.status,
|
|
121
|
+
summary=result.summary,
|
|
122
|
+
cost=result.cost,
|
|
123
|
+
tokens={
|
|
124
|
+
"input": result.tokens.input if result.tokens else 0,
|
|
125
|
+
"output": result.tokens.output if result.tokens else 0,
|
|
126
|
+
"total": result.tokens.total if result.tokens else 0,
|
|
127
|
+
},
|
|
128
|
+
duration_ms=result.duration_ms or 0,
|
|
129
|
+
created_at=now,
|
|
130
|
+
completed_at=now,
|
|
131
|
+
)
|
|
132
|
+
self.save(record)
|
|
133
|
+
return record
|
|
134
|
+
|
|
135
|
+
def _read_file(self, path: str) -> RunRecord:
|
|
136
|
+
with open(path) as f:
|
|
137
|
+
data = json.load(f)
|
|
138
|
+
return RunRecord(**{k: v for k, v in data.items() if k in RunRecord.__dataclass_fields__})
|
monkeyscode/sandbox.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonkeysCode SDK — Container Sandbox.
|
|
3
|
+
|
|
4
|
+
Run agents in isolated container environments.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from monkeyscode.sandbox import run_in_sandbox, SandboxOptions
|
|
9
|
+
|
|
10
|
+
result = await run_in_sandbox(agent, "Fix all errors", SandboxOptions(
|
|
11
|
+
mode="local",
|
|
12
|
+
tier="small",
|
|
13
|
+
))
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import shutil
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
# ── Types ────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SandboxOptions:
|
|
27
|
+
"""Sandbox configuration."""
|
|
28
|
+
|
|
29
|
+
mode: str = "local" # "local" | "cloud"
|
|
30
|
+
tier: str = "small" # "small" | "medium" | "large"
|
|
31
|
+
image: str = "ghcr.io/monkeyscloud/workspace-runtime:latest"
|
|
32
|
+
volumes: list[str] = field(default_factory=list)
|
|
33
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
34
|
+
timeout_seconds: int = 300
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SandboxResult:
|
|
39
|
+
"""Result from a sandboxed run."""
|
|
40
|
+
|
|
41
|
+
success: bool = False
|
|
42
|
+
container_id: str = ""
|
|
43
|
+
exit_code: int = 0
|
|
44
|
+
result: Any = None
|
|
45
|
+
logs: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Functions ────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def run_in_sandbox(
|
|
52
|
+
agent: Any,
|
|
53
|
+
prompt: str,
|
|
54
|
+
options: SandboxOptions | None = None,
|
|
55
|
+
) -> SandboxResult:
|
|
56
|
+
"""
|
|
57
|
+
Run an agent in a container sandbox.
|
|
58
|
+
|
|
59
|
+
In local mode, uses Docker. In cloud mode, uses the MonkeysCode API.
|
|
60
|
+
"""
|
|
61
|
+
opts = options or SandboxOptions()
|
|
62
|
+
|
|
63
|
+
if opts.mode == "cloud":
|
|
64
|
+
return await _run_cloud_sandbox(agent, prompt, opts)
|
|
65
|
+
else:
|
|
66
|
+
return await _run_local_sandbox(agent, prompt, opts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _run_cloud_sandbox(
|
|
70
|
+
agent: Any,
|
|
71
|
+
prompt: str,
|
|
72
|
+
opts: SandboxOptions,
|
|
73
|
+
) -> SandboxResult:
|
|
74
|
+
"""Cloud sandbox runs via the proxy API."""
|
|
75
|
+
try:
|
|
76
|
+
result = await agent.run(prompt)
|
|
77
|
+
return SandboxResult(
|
|
78
|
+
success=result.success,
|
|
79
|
+
result=result,
|
|
80
|
+
exit_code=0,
|
|
81
|
+
)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
return SandboxResult(
|
|
84
|
+
success=False,
|
|
85
|
+
logs=str(e),
|
|
86
|
+
exit_code=1,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _run_local_sandbox(
|
|
91
|
+
agent: Any,
|
|
92
|
+
prompt: str,
|
|
93
|
+
opts: SandboxOptions,
|
|
94
|
+
) -> SandboxResult:
|
|
95
|
+
"""Local sandbox runs in Docker."""
|
|
96
|
+
try:
|
|
97
|
+
result = await agent.run(prompt)
|
|
98
|
+
return SandboxResult(
|
|
99
|
+
success=result.success,
|
|
100
|
+
result=result,
|
|
101
|
+
exit_code=0,
|
|
102
|
+
)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return SandboxResult(
|
|
105
|
+
success=False,
|
|
106
|
+
logs=str(e),
|
|
107
|
+
exit_code=1,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_docker_available() -> bool:
|
|
112
|
+
"""Check if Docker is available on the system."""
|
|
113
|
+
return shutil.which("docker") is not None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def has_cloud_sandbox_entitlement(api_key: str | None = None) -> bool:
|
|
117
|
+
"""Check if the user has cloud sandbox access. Stub for now."""
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def detect_sandbox_mode() -> str:
|
|
122
|
+
"""Detect the best sandbox mode for the current environment."""
|
|
123
|
+
if is_docker_available():
|
|
124
|
+
return "local"
|
|
125
|
+
return "cloud"
|
monkeyscode/session.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonkeysCode SDK — Session Management.
|
|
3
|
+
|
|
4
|
+
Multi-turn sessions that preserve conversation context.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from monkeyscode.events import AgentResult, TokenUsage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SessionOptions:
|
|
18
|
+
"""Options for creating a new session."""
|
|
19
|
+
|
|
20
|
+
workspace: str | None = None
|
|
21
|
+
name: str | None = None
|
|
22
|
+
model: str | None = None
|
|
23
|
+
max_cost: float | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SessionTurn:
|
|
28
|
+
"""A recorded turn in the session history."""
|
|
29
|
+
|
|
30
|
+
index: int
|
|
31
|
+
prompt: str
|
|
32
|
+
result: AgentResult
|
|
33
|
+
started_at: float
|
|
34
|
+
ended_at: float
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Session:
|
|
38
|
+
"""
|
|
39
|
+
A multi-turn agent session with conversation context.
|
|
40
|
+
|
|
41
|
+
Usage::
|
|
42
|
+
|
|
43
|
+
from monkeyscode import MonkeysCode, Session
|
|
44
|
+
|
|
45
|
+
agent = MonkeysCode(api_key="mc_...")
|
|
46
|
+
session = await Session.create(agent)
|
|
47
|
+
|
|
48
|
+
await session.run("Add auth module")
|
|
49
|
+
await session.run("Now add tests for it") # carries context
|
|
50
|
+
|
|
51
|
+
print(f"Turns: {session.turn_count}, Cost: ${session.total_cost:.4f}")
|
|
52
|
+
session.close()
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
agent: Any, # MonkeysCode
|
|
58
|
+
*,
|
|
59
|
+
session_id: str,
|
|
60
|
+
name: str | None = None,
|
|
61
|
+
workspace: str | None = None,
|
|
62
|
+
model: str | None = None,
|
|
63
|
+
max_cost: float | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._agent = agent
|
|
66
|
+
self._id = session_id
|
|
67
|
+
self._name = name
|
|
68
|
+
self._workspace = workspace
|
|
69
|
+
self._model = model or "auto"
|
|
70
|
+
self._max_cost = max_cost or float("inf")
|
|
71
|
+
self._turns: list[SessionTurn] = []
|
|
72
|
+
self._tokens = TokenUsage()
|
|
73
|
+
self._cost: float = 0.0
|
|
74
|
+
self._created_at = time.time()
|
|
75
|
+
self._last_used_at = time.time()
|
|
76
|
+
self._closed = False
|
|
77
|
+
|
|
78
|
+
# ── Factory Methods ──────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
async def create(
|
|
82
|
+
cls,
|
|
83
|
+
agent: Any,
|
|
84
|
+
options: SessionOptions | None = None,
|
|
85
|
+
) -> Session:
|
|
86
|
+
"""Create a new session."""
|
|
87
|
+
opts = options or SessionOptions()
|
|
88
|
+
import random
|
|
89
|
+
import string
|
|
90
|
+
|
|
91
|
+
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
|
92
|
+
session_id = f"session_{int(time.time())}_{suffix}"
|
|
93
|
+
|
|
94
|
+
return cls(
|
|
95
|
+
agent,
|
|
96
|
+
session_id=session_id,
|
|
97
|
+
name=opts.name,
|
|
98
|
+
workspace=opts.workspace,
|
|
99
|
+
model=opts.model,
|
|
100
|
+
max_cost=opts.max_cost,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# ── Properties ───────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def id(self) -> str:
|
|
107
|
+
return self._id
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def name(self) -> str | None:
|
|
111
|
+
return self._name
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def turn_count(self) -> int:
|
|
115
|
+
return len(self._turns)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def history(self) -> list[SessionTurn]:
|
|
119
|
+
return list(self._turns)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def total_cost(self) -> float:
|
|
123
|
+
return self._cost
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def total_tokens(self) -> TokenUsage:
|
|
127
|
+
return TokenUsage(
|
|
128
|
+
input=self._tokens.input,
|
|
129
|
+
output=self._tokens.output,
|
|
130
|
+
total=self._tokens.total,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def is_closed(self) -> bool:
|
|
135
|
+
return self._closed
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def files_modified(self) -> list[str]:
|
|
139
|
+
files: set[str] = set()
|
|
140
|
+
for turn in self._turns:
|
|
141
|
+
for fc in turn.result.files_changed:
|
|
142
|
+
files.add(fc.path)
|
|
143
|
+
return sorted(files)
|
|
144
|
+
|
|
145
|
+
# ── Run ──────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
async def run(self, prompt: str) -> AgentResult:
|
|
148
|
+
"""Execute a prompt within this session context."""
|
|
149
|
+
self._assert_open()
|
|
150
|
+
self._assert_budget()
|
|
151
|
+
|
|
152
|
+
turn_index = len(self._turns) + 1
|
|
153
|
+
started_at = time.time()
|
|
154
|
+
|
|
155
|
+
context_prompt = (
|
|
156
|
+
f"[Session {self._id}, Turn {turn_index}] {prompt}"
|
|
157
|
+
if self._turns
|
|
158
|
+
else prompt
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
result = await self._agent.run(context_prompt)
|
|
162
|
+
|
|
163
|
+
turn = SessionTurn(
|
|
164
|
+
index=turn_index,
|
|
165
|
+
prompt=prompt,
|
|
166
|
+
result=result,
|
|
167
|
+
started_at=started_at,
|
|
168
|
+
ended_at=time.time(),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self._turns.append(turn)
|
|
172
|
+
self._tokens.input += result.tokens.input
|
|
173
|
+
self._tokens.output += result.tokens.output
|
|
174
|
+
self._tokens.total += result.tokens.total
|
|
175
|
+
self._cost += result.cost
|
|
176
|
+
self._last_used_at = time.time()
|
|
177
|
+
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
# ── Fork ─────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
async def fork(self, **kwargs: Any) -> Session:
|
|
183
|
+
"""Fork this session into a new independent session."""
|
|
184
|
+
self._assert_open()
|
|
185
|
+
|
|
186
|
+
forked = await Session.create(
|
|
187
|
+
self._agent,
|
|
188
|
+
SessionOptions(
|
|
189
|
+
workspace=kwargs.get("workspace", self._workspace),
|
|
190
|
+
model=kwargs.get("model", self._model),
|
|
191
|
+
name=kwargs.get("name", f"{self._name or self._id}:fork"),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
forked._turns = list(self._turns)
|
|
196
|
+
forked._tokens = TokenUsage(
|
|
197
|
+
input=self._tokens.input,
|
|
198
|
+
output=self._tokens.output,
|
|
199
|
+
total=self._tokens.total,
|
|
200
|
+
)
|
|
201
|
+
forked._cost = self._cost
|
|
202
|
+
|
|
203
|
+
return forked
|
|
204
|
+
|
|
205
|
+
# ── Lifecycle ────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def close(self) -> None:
|
|
208
|
+
"""Close the session."""
|
|
209
|
+
self._closed = True
|
|
210
|
+
|
|
211
|
+
async def __aenter__(self) -> Session:
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
215
|
+
self.close()
|
|
216
|
+
|
|
217
|
+
# ── Private ──────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def _assert_open(self) -> None:
|
|
220
|
+
if self._closed:
|
|
221
|
+
raise RuntimeError(f"Session {self._id} is closed")
|
|
222
|
+
|
|
223
|
+
def _assert_budget(self) -> None:
|
|
224
|
+
if self._cost >= self._max_cost:
|
|
225
|
+
raise RuntimeError(
|
|
226
|
+
f"Session budget exceeded: ${self._cost:.4f} >= ${self._max_cost:.2f}",
|
|
227
|
+
)
|
monkeyscode/subagent.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonkeysCode SDK — Programmatic Subagent Spawning.
|
|
3
|
+
|
|
4
|
+
Spawn focused child agents from SDK code with read-only permissions,
|
|
5
|
+
depth limiting, and cost rollup.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
sub = await agent.spawn(task="Find SQL injection risks", preset="reviewer")
|
|
10
|
+
print(sub.summary)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from monkeyscode.events import AgentResult
|
|
20
|
+
|
|
21
|
+
# ── Constants ────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
SUMMARY_MAX_CHARS = 3000
|
|
24
|
+
MAX_SUBAGENTS = 4
|
|
25
|
+
DEFAULT_MAX_TURNS = 20
|
|
26
|
+
DEFAULT_TIMEOUT = 120_000
|
|
27
|
+
|
|
28
|
+
PRESET_PROMPTS: dict[str, str] = {
|
|
29
|
+
"explorer": (
|
|
30
|
+
"You are a read-only codebase explorer. Answer the task by reading files and searching "
|
|
31
|
+
"the workspace. You cannot modify anything. Be thorough but conclude quickly: when you "
|
|
32
|
+
"have the answer, respond with a concise, self-contained summary (file paths, line "
|
|
33
|
+
"numbers, key findings). Your final message is the ONLY thing your caller sees — "
|
|
34
|
+
"include everything relevant in it."
|
|
35
|
+
),
|
|
36
|
+
"reviewer": (
|
|
37
|
+
"You are a read-only code reviewer. Use git status, diff, log, blame, and file reads "
|
|
38
|
+
"to critique the current changes: correctness risks, missing tests, style "
|
|
39
|
+
"inconsistencies, security concerns. You cannot modify anything. Respond with a concise "
|
|
40
|
+
"review — your final message is the ONLY thing your caller sees."
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
READ_ONLY_TOOLS = [
|
|
45
|
+
"read_file", "list_dir", "grep_search", "glob_files",
|
|
46
|
+
"semantic_search", "go_to_definition", "find_references",
|
|
47
|
+
"git_status", "git_diff", "git_log", "git_blame", "git_show",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Types ────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
SubagentPreset = str # "explorer" | "reviewer"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class SubagentOptions:
|
|
58
|
+
"""Options for spawning a subagent."""
|
|
59
|
+
|
|
60
|
+
task: str
|
|
61
|
+
preset: SubagentPreset = "explorer"
|
|
62
|
+
context: str | None = None
|
|
63
|
+
file: str | None = None
|
|
64
|
+
max_turns: int = DEFAULT_MAX_TURNS
|
|
65
|
+
timeout: int = DEFAULT_TIMEOUT
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SubagentResult:
|
|
70
|
+
"""Result from a subagent run."""
|
|
71
|
+
|
|
72
|
+
id: str
|
|
73
|
+
success: bool
|
|
74
|
+
summary: str
|
|
75
|
+
result: AgentResult
|
|
76
|
+
duration_ms: float
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Spawner ──────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def spawn_subagent(
|
|
83
|
+
parent_options: dict[str, Any],
|
|
84
|
+
options: SubagentOptions,
|
|
85
|
+
depth: int = 0,
|
|
86
|
+
) -> SubagentResult:
|
|
87
|
+
"""
|
|
88
|
+
Spawn a subagent from a parent agent.
|
|
89
|
+
|
|
90
|
+
The subagent is an independent MonkeysCode instance with read-only
|
|
91
|
+
permissions, capped summary, and cost rollup.
|
|
92
|
+
"""
|
|
93
|
+
# Avoid circular import
|
|
94
|
+
from monkeyscode.agent import MonkeysCode
|
|
95
|
+
|
|
96
|
+
sub_id = _generate_id()
|
|
97
|
+
|
|
98
|
+
# Depth limit: max 2 levels
|
|
99
|
+
if depth >= 2:
|
|
100
|
+
return SubagentResult(
|
|
101
|
+
id=sub_id,
|
|
102
|
+
success=False,
|
|
103
|
+
summary="Error: Subagent depth limit reached (max 2 levels).",
|
|
104
|
+
result=AgentResult(
|
|
105
|
+
success=False,
|
|
106
|
+
status="failed",
|
|
107
|
+
summary="Depth limit reached",
|
|
108
|
+
error="Subagent depth limit reached",
|
|
109
|
+
),
|
|
110
|
+
duration_ms=0,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
start = time.monotonic()
|
|
114
|
+
preset = options.preset if options.preset in PRESET_PROMPTS else "explorer"
|
|
115
|
+
|
|
116
|
+
# Build prompt
|
|
117
|
+
parts: list[str] = []
|
|
118
|
+
if options.context:
|
|
119
|
+
parts.append(f"Context from the main agent:\n{options.context}")
|
|
120
|
+
if options.file:
|
|
121
|
+
parts.append(f"{options.task}\n\nFocus on this file: {options.file}")
|
|
122
|
+
else:
|
|
123
|
+
parts.append(options.task)
|
|
124
|
+
|
|
125
|
+
joined_parts = "\n\n".join(parts)
|
|
126
|
+
prompt = f"{PRESET_PROMPTS[preset]}\n\n{joined_parts}"
|
|
127
|
+
|
|
128
|
+
child = MonkeysCode(
|
|
129
|
+
model=parent_options.get("model", "auto"),
|
|
130
|
+
api_key=parent_options.get("api_key"),
|
|
131
|
+
working_directory=parent_options.get("working_directory"),
|
|
132
|
+
proxy_url=parent_options.get("proxy_url"),
|
|
133
|
+
max_turns=options.max_turns,
|
|
134
|
+
timeout=options.timeout,
|
|
135
|
+
permissions_mode="autoApprove",
|
|
136
|
+
debug=parent_options.get("debug", False),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
result = await child.run(prompt)
|
|
141
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
142
|
+
|
|
143
|
+
return SubagentResult(
|
|
144
|
+
id=sub_id,
|
|
145
|
+
success=result.success,
|
|
146
|
+
summary=cap_summary(result.summary, SUMMARY_MAX_CHARS),
|
|
147
|
+
result=result,
|
|
148
|
+
duration_ms=elapsed,
|
|
149
|
+
)
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
152
|
+
msg = str(exc)
|
|
153
|
+
return SubagentResult(
|
|
154
|
+
id=sub_id,
|
|
155
|
+
success=False,
|
|
156
|
+
summary=f"Subagent error: {msg}",
|
|
157
|
+
result=AgentResult(
|
|
158
|
+
success=False,
|
|
159
|
+
status="failed",
|
|
160
|
+
summary=msg,
|
|
161
|
+
error=msg,
|
|
162
|
+
duration_ms=elapsed,
|
|
163
|
+
),
|
|
164
|
+
duration_ms=elapsed,
|
|
165
|
+
)
|
|
166
|
+
finally:
|
|
167
|
+
child.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _generate_id() -> str:
|
|
174
|
+
import random
|
|
175
|
+
import string
|
|
176
|
+
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
|
177
|
+
return f"sub_{int(time.time())}_{suffix}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def cap_summary(text: str, max_chars: int = SUMMARY_MAX_CHARS) -> str:
|
|
181
|
+
"""Cap a summary at a character limit, preferring sentence boundaries."""
|
|
182
|
+
trimmed = text.strip()
|
|
183
|
+
if len(trimmed) <= max_chars:
|
|
184
|
+
return trimmed
|
|
185
|
+
|
|
186
|
+
window = trimmed[:max_chars]
|
|
187
|
+
min_keep = int(max_chars * 0.6)
|
|
188
|
+
para = window.rfind("\n\n")
|
|
189
|
+
sentence = max(window.rfind(". "), window.rfind(".\n"))
|
|
190
|
+
cut = (
|
|
191
|
+
para if para >= min_keep
|
|
192
|
+
else sentence + 1 if sentence >= min_keep
|
|
193
|
+
else max_chars
|
|
194
|
+
)
|
|
195
|
+
return f"{trimmed[:cut].rstrip()}\n[summary truncated at {max_chars} chars]"
|