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/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
+ )
@@ -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]"