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/mcp.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonkeysCode SDK — MCP Server Manager.
|
|
3
|
+
|
|
4
|
+
Launch and manage Model Context Protocol (MCP) server processes.
|
|
5
|
+
Discover and call tools exposed by MCP servers.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from monkeyscode.mcp import McpManager
|
|
10
|
+
|
|
11
|
+
mcp = McpManager()
|
|
12
|
+
await mcp.connect("postgres", McpServerConfig(
|
|
13
|
+
command="npx",
|
|
14
|
+
args=["@modelcontextprotocol/server-postgres"],
|
|
15
|
+
env={"DATABASE_URL": "postgresql://localhost/mydb"},
|
|
16
|
+
))
|
|
17
|
+
|
|
18
|
+
tools = await mcp.list_tools("postgres")
|
|
19
|
+
result = await mcp.call_tool("postgres", "query", {"sql": "SELECT 1"})
|
|
20
|
+
|
|
21
|
+
mcp.disconnect_all()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
# ── Types ────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class McpServerConfig:
|
|
37
|
+
"""MCP server configuration."""
|
|
38
|
+
|
|
39
|
+
command: str
|
|
40
|
+
args: list[str] = field(default_factory=list)
|
|
41
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
cwd: str | None = None
|
|
43
|
+
timeout: float = 30.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class McpTool:
|
|
48
|
+
"""An MCP tool descriptor."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
description: str
|
|
52
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class McpToolResult:
|
|
57
|
+
"""Result from calling an MCP tool."""
|
|
58
|
+
|
|
59
|
+
content: list[dict[str, Any]] = field(default_factory=list)
|
|
60
|
+
is_error: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class McpServerStatus:
|
|
65
|
+
"""Status of an MCP server."""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
pid: int | None = None
|
|
69
|
+
connected: bool = False
|
|
70
|
+
tool_count: int = 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── JSON-RPC Helpers ─────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _JsonRpcTransport:
|
|
77
|
+
"""Communicate with an MCP server over stdin/stdout JSON-RPC."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, process: asyncio.subprocess.Process) -> None:
|
|
80
|
+
self._process = process
|
|
81
|
+
self._request_id = 0
|
|
82
|
+
|
|
83
|
+
async def request(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
|
84
|
+
self._request_id += 1
|
|
85
|
+
msg = {
|
|
86
|
+
"jsonrpc": "2.0",
|
|
87
|
+
"id": self._request_id,
|
|
88
|
+
"method": method,
|
|
89
|
+
"params": params or {},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
stdin = self._process.stdin
|
|
93
|
+
stdout = self._process.stdout
|
|
94
|
+
assert stdin is not None and stdout is not None
|
|
95
|
+
|
|
96
|
+
line = json.dumps(msg) + "\n"
|
|
97
|
+
stdin.write(line.encode())
|
|
98
|
+
await stdin.drain()
|
|
99
|
+
|
|
100
|
+
response_line = await asyncio.wait_for(stdout.readline(), timeout=30.0)
|
|
101
|
+
if not response_line:
|
|
102
|
+
raise RuntimeError("MCP server closed connection")
|
|
103
|
+
|
|
104
|
+
data = json.loads(response_line.decode())
|
|
105
|
+
if "error" in data:
|
|
106
|
+
raise RuntimeError(f"MCP error: {data['error']}")
|
|
107
|
+
return data.get("result")
|
|
108
|
+
|
|
109
|
+
async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
110
|
+
msg = {
|
|
111
|
+
"jsonrpc": "2.0",
|
|
112
|
+
"method": method,
|
|
113
|
+
"params": params or {},
|
|
114
|
+
}
|
|
115
|
+
stdin = self._process.stdin
|
|
116
|
+
assert stdin is not None
|
|
117
|
+
line = json.dumps(msg) + "\n"
|
|
118
|
+
stdin.write(line.encode())
|
|
119
|
+
await stdin.drain()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── MCP Manager ──────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class McpManager:
|
|
126
|
+
"""
|
|
127
|
+
Manages MCP server lifecycle and tool invocations.
|
|
128
|
+
|
|
129
|
+
Launches servers as child processes, communicates via JSON-RPC over
|
|
130
|
+
stdin/stdout, and provides tool discovery and invocation.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(self) -> None:
|
|
134
|
+
self._servers: dict[str, _McpConnection] = {}
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def server_names(self) -> list[str]:
|
|
138
|
+
return list(self._servers.keys())
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def server_count(self) -> int:
|
|
142
|
+
return len(self._servers)
|
|
143
|
+
|
|
144
|
+
async def connect(self, name: str, config: McpServerConfig) -> McpServerStatus:
|
|
145
|
+
"""Launch and connect to an MCP server."""
|
|
146
|
+
env = {**os.environ, **config.env}
|
|
147
|
+
|
|
148
|
+
process = await asyncio.create_subprocess_exec(
|
|
149
|
+
config.command,
|
|
150
|
+
*config.args,
|
|
151
|
+
stdin=asyncio.subprocess.PIPE,
|
|
152
|
+
stdout=asyncio.subprocess.PIPE,
|
|
153
|
+
stderr=asyncio.subprocess.PIPE,
|
|
154
|
+
cwd=config.cwd,
|
|
155
|
+
env=env,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
transport = _JsonRpcTransport(process)
|
|
159
|
+
|
|
160
|
+
# Initialize the server
|
|
161
|
+
try:
|
|
162
|
+
await transport.request("initialize", {
|
|
163
|
+
"protocolVersion": "2024-11-05",
|
|
164
|
+
"capabilities": {},
|
|
165
|
+
"clientInfo": {"name": "monkeyscode-sdk-python", "version": "1.0.0"},
|
|
166
|
+
})
|
|
167
|
+
await transport.notify("notifications/initialized")
|
|
168
|
+
except Exception:
|
|
169
|
+
process.kill()
|
|
170
|
+
raise
|
|
171
|
+
|
|
172
|
+
# Discover tools
|
|
173
|
+
try:
|
|
174
|
+
result = await transport.request("tools/list")
|
|
175
|
+
tools = [
|
|
176
|
+
McpTool(
|
|
177
|
+
name=t["name"],
|
|
178
|
+
description=t.get("description", ""),
|
|
179
|
+
input_schema=t.get("inputSchema", {}),
|
|
180
|
+
)
|
|
181
|
+
for t in (result.get("tools", []) if result else [])
|
|
182
|
+
]
|
|
183
|
+
except Exception:
|
|
184
|
+
tools = []
|
|
185
|
+
|
|
186
|
+
conn = _McpConnection(
|
|
187
|
+
name=name,
|
|
188
|
+
config=config,
|
|
189
|
+
process=process,
|
|
190
|
+
transport=transport,
|
|
191
|
+
tools=tools,
|
|
192
|
+
)
|
|
193
|
+
self._servers[name] = conn
|
|
194
|
+
|
|
195
|
+
return McpServerStatus(
|
|
196
|
+
name=name,
|
|
197
|
+
pid=process.pid,
|
|
198
|
+
connected=True,
|
|
199
|
+
tool_count=len(tools),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
async def disconnect(self, name: str) -> None:
|
|
203
|
+
"""Disconnect and kill an MCP server."""
|
|
204
|
+
conn = self._servers.pop(name, None)
|
|
205
|
+
if conn is None:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
conn.process.terminate()
|
|
210
|
+
await asyncio.wait_for(conn.process.wait(), timeout=5.0)
|
|
211
|
+
except (asyncio.TimeoutError, ProcessLookupError):
|
|
212
|
+
conn.process.kill()
|
|
213
|
+
|
|
214
|
+
def disconnect_all(self) -> None:
|
|
215
|
+
"""Disconnect all MCP servers synchronously."""
|
|
216
|
+
for name in list(self._servers.keys()):
|
|
217
|
+
conn = self._servers.pop(name, None)
|
|
218
|
+
if conn and conn.process.returncode is None:
|
|
219
|
+
try:
|
|
220
|
+
conn.process.kill()
|
|
221
|
+
except ProcessLookupError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
async def list_tools(self, name: str) -> list[McpTool]:
|
|
225
|
+
"""List tools available from a named server."""
|
|
226
|
+
conn = self._servers.get(name)
|
|
227
|
+
if conn is None:
|
|
228
|
+
raise KeyError(f"MCP server not connected: {name}")
|
|
229
|
+
return conn.tools
|
|
230
|
+
|
|
231
|
+
async def call_tool(
|
|
232
|
+
self,
|
|
233
|
+
server_name: str,
|
|
234
|
+
tool_name: str,
|
|
235
|
+
arguments: dict[str, Any] | None = None,
|
|
236
|
+
) -> McpToolResult:
|
|
237
|
+
"""Call a tool on a named MCP server."""
|
|
238
|
+
conn = self._servers.get(server_name)
|
|
239
|
+
if conn is None:
|
|
240
|
+
raise KeyError(f"MCP server not connected: {server_name}")
|
|
241
|
+
|
|
242
|
+
result = await conn.transport.request("tools/call", {
|
|
243
|
+
"name": tool_name,
|
|
244
|
+
"arguments": arguments or {},
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
if result is None:
|
|
248
|
+
return McpToolResult(is_error=True)
|
|
249
|
+
|
|
250
|
+
return McpToolResult(
|
|
251
|
+
content=result.get("content", []),
|
|
252
|
+
is_error=result.get("isError", False),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def status(self, name: str) -> McpServerStatus:
|
|
256
|
+
"""Get the status of a named server."""
|
|
257
|
+
conn = self._servers.get(name)
|
|
258
|
+
if conn is None:
|
|
259
|
+
return McpServerStatus(name=name, connected=False)
|
|
260
|
+
return McpServerStatus(
|
|
261
|
+
name=name,
|
|
262
|
+
pid=conn.process.pid,
|
|
263
|
+
connected=conn.process.returncode is None,
|
|
264
|
+
tool_count=len(conn.tools),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def all_tools(self) -> list[McpTool]:
|
|
268
|
+
"""List tools from all connected servers."""
|
|
269
|
+
tools: list[McpTool] = []
|
|
270
|
+
for conn in self._servers.values():
|
|
271
|
+
tools.extend(conn.tools)
|
|
272
|
+
return tools
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass
|
|
276
|
+
class _McpConnection:
|
|
277
|
+
"""Internal connection state for an MCP server."""
|
|
278
|
+
|
|
279
|
+
name: str
|
|
280
|
+
config: McpServerConfig
|
|
281
|
+
process: asyncio.subprocess.Process
|
|
282
|
+
transport: _JsonRpcTransport
|
|
283
|
+
tools: list[McpTool] = field(default_factory=list)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonkeysCode SDK — Multi-Agent Orchestrator.
|
|
3
|
+
|
|
4
|
+
Run multiple agents in parallel or fan out across targets.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from monkeyscode.orchestrator import run_parallel, run_fan_out
|
|
9
|
+
|
|
10
|
+
result = await run_parallel(agent, [
|
|
11
|
+
"Fix auth.py",
|
|
12
|
+
"Fix db.py",
|
|
13
|
+
"Fix api.py",
|
|
14
|
+
], concurrency=3)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from monkeyscode.events import AgentResult, TokenUsage
|
|
25
|
+
|
|
26
|
+
# ── Types ────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AgentSlotResult:
|
|
31
|
+
"""Result from one agent slot in a parallel run."""
|
|
32
|
+
|
|
33
|
+
index: int
|
|
34
|
+
prompt: str
|
|
35
|
+
result: AgentResult | None = None
|
|
36
|
+
error: str | None = None
|
|
37
|
+
duration_ms: float = 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class OrchestratorResult:
|
|
42
|
+
"""Combined result of a parallel or fan-out run."""
|
|
43
|
+
|
|
44
|
+
results: list[AgentSlotResult] = field(default_factory=list)
|
|
45
|
+
total_cost: float = 0.0
|
|
46
|
+
total_tokens: TokenUsage = field(default_factory=TokenUsage)
|
|
47
|
+
duration_ms: float = 0
|
|
48
|
+
success_count: int = 0
|
|
49
|
+
fail_count: int = 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Parallel ─────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def run_parallel(
|
|
56
|
+
parent: Any,
|
|
57
|
+
prompts: list[str],
|
|
58
|
+
*,
|
|
59
|
+
concurrency: int = 0,
|
|
60
|
+
) -> OrchestratorResult:
|
|
61
|
+
"""
|
|
62
|
+
Run multiple prompts concurrently using separate agent instances.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
parent: The MonkeysCode agent to clone settings from.
|
|
66
|
+
prompts: List of prompts to run in parallel.
|
|
67
|
+
concurrency: Max simultaneous agents (default: len(prompts), max: 10).
|
|
68
|
+
"""
|
|
69
|
+
if not prompts:
|
|
70
|
+
raise ValueError("At least one prompt is required")
|
|
71
|
+
|
|
72
|
+
if concurrency <= 0:
|
|
73
|
+
concurrency = len(prompts)
|
|
74
|
+
concurrency = min(concurrency, 10)
|
|
75
|
+
|
|
76
|
+
start = time.monotonic()
|
|
77
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
78
|
+
results: list[AgentSlotResult] = [
|
|
79
|
+
AgentSlotResult(index=i, prompt=p) for i, p in enumerate(prompts)
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
async def _run_slot(idx: int, prompt: str) -> None:
|
|
83
|
+
async with semaphore:
|
|
84
|
+
slot_start = time.monotonic()
|
|
85
|
+
try:
|
|
86
|
+
from monkeyscode.agent import MonkeysCode
|
|
87
|
+
child = MonkeysCode(
|
|
88
|
+
model=parent._model,
|
|
89
|
+
api_key=parent._api_key,
|
|
90
|
+
working_directory=parent._working_directory,
|
|
91
|
+
proxy_url=parent._proxy_url,
|
|
92
|
+
max_turns=parent._max_turns,
|
|
93
|
+
timeout=parent._timeout,
|
|
94
|
+
permissions_mode=parent._permissions_mode,
|
|
95
|
+
debug=parent._debug,
|
|
96
|
+
)
|
|
97
|
+
result = await child.run(prompt)
|
|
98
|
+
elapsed = (time.monotonic() - slot_start) * 1000
|
|
99
|
+
results[idx] = AgentSlotResult(
|
|
100
|
+
index=idx,
|
|
101
|
+
prompt=prompt,
|
|
102
|
+
result=result,
|
|
103
|
+
duration_ms=elapsed,
|
|
104
|
+
)
|
|
105
|
+
child.close()
|
|
106
|
+
except Exception as e:
|
|
107
|
+
elapsed = (time.monotonic() - slot_start) * 1000
|
|
108
|
+
results[idx] = AgentSlotResult(
|
|
109
|
+
index=idx,
|
|
110
|
+
prompt=prompt,
|
|
111
|
+
error=str(e),
|
|
112
|
+
duration_ms=elapsed,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
tasks = [_run_slot(i, p) for i, p in enumerate(prompts)]
|
|
116
|
+
await asyncio.gather(*tasks)
|
|
117
|
+
|
|
118
|
+
total_cost = 0.0
|
|
119
|
+
total_input = 0
|
|
120
|
+
total_output = 0
|
|
121
|
+
success_count = 0
|
|
122
|
+
fail_count = 0
|
|
123
|
+
|
|
124
|
+
for slot in results:
|
|
125
|
+
if slot.result is not None:
|
|
126
|
+
total_cost += slot.result.cost
|
|
127
|
+
total_input += slot.result.tokens.input
|
|
128
|
+
total_output += slot.result.tokens.output
|
|
129
|
+
if slot.result.success:
|
|
130
|
+
success_count += 1
|
|
131
|
+
else:
|
|
132
|
+
fail_count += 1
|
|
133
|
+
else:
|
|
134
|
+
fail_count += 1
|
|
135
|
+
|
|
136
|
+
return OrchestratorResult(
|
|
137
|
+
results=results,
|
|
138
|
+
total_cost=total_cost,
|
|
139
|
+
total_tokens=TokenUsage(
|
|
140
|
+
input=total_input,
|
|
141
|
+
output=total_output,
|
|
142
|
+
total=total_input + total_output,
|
|
143
|
+
),
|
|
144
|
+
duration_ms=(time.monotonic() - start) * 1000,
|
|
145
|
+
success_count=success_count,
|
|
146
|
+
fail_count=fail_count,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def run_fan_out(
|
|
151
|
+
parent: Any,
|
|
152
|
+
prompt: str,
|
|
153
|
+
targets: list[str],
|
|
154
|
+
*,
|
|
155
|
+
concurrency: int = 0,
|
|
156
|
+
) -> OrchestratorResult:
|
|
157
|
+
"""
|
|
158
|
+
Run the same prompt against multiple targets concurrently.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
parent: The MonkeysCode agent to clone settings from.
|
|
162
|
+
prompt: The base instruction.
|
|
163
|
+
targets: Items to fan out over (file paths, modules, etc.).
|
|
164
|
+
concurrency: Max simultaneous agents.
|
|
165
|
+
"""
|
|
166
|
+
if not targets:
|
|
167
|
+
raise ValueError("At least one target is required")
|
|
168
|
+
|
|
169
|
+
prompts = [f"{prompt}\n\nTarget: {target}" for target in targets]
|
|
170
|
+
return await run_parallel(parent, prompts, concurrency=concurrency)
|
monkeyscode/otel.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonkeysCode SDK — OpenTelemetry Exporter.
|
|
3
|
+
|
|
4
|
+
Send OTLP HTTP traces to Grafana, Datadog, Honeycomb, etc.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from monkeyscode.otel import OtelExporter
|
|
9
|
+
|
|
10
|
+
exporter = OtelExporter(endpoint="http://localhost:4318")
|
|
11
|
+
# Events are recorded automatically when wired into the agent
|
|
12
|
+
await exporter.flush()
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import random
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
# ── Types ────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class OtelOptions:
|
|
29
|
+
"""OpenTelemetry exporter configuration."""
|
|
30
|
+
|
|
31
|
+
endpoint: str = ""
|
|
32
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
33
|
+
service_name: str = "monkeyscode-sdk-python"
|
|
34
|
+
debug: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class _OtelSpan:
|
|
39
|
+
trace_id: str
|
|
40
|
+
span_id: str
|
|
41
|
+
parent_span_id: str = ""
|
|
42
|
+
name: str = ""
|
|
43
|
+
kind: int = 0
|
|
44
|
+
start_time_ns: int = 0
|
|
45
|
+
end_time_ns: int = 0
|
|
46
|
+
status_code: int = 0
|
|
47
|
+
attributes: list[dict[str, Any]] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Exporter ─────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OtelExporter:
|
|
54
|
+
"""
|
|
55
|
+
OTLP HTTP exporter for agent traces.
|
|
56
|
+
|
|
57
|
+
Records tool calls, run durations, and cost as spans.
|
|
58
|
+
Flush to send to your observability backend.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, options: OtelOptions | None = None) -> None:
|
|
62
|
+
opts = options or OtelOptions()
|
|
63
|
+
endpoint = opts.endpoint.rstrip("/")
|
|
64
|
+
if endpoint and not endpoint.endswith("/v1/traces"):
|
|
65
|
+
endpoint += "/v1/traces"
|
|
66
|
+
|
|
67
|
+
self._endpoint = endpoint
|
|
68
|
+
self._headers = opts.headers
|
|
69
|
+
self._service_name = opts.service_name
|
|
70
|
+
self._debug = opts.debug
|
|
71
|
+
self._trace_id = _generate_hex(32)
|
|
72
|
+
self._spans: list[_OtelSpan] = []
|
|
73
|
+
self._pending: dict[str, _OtelSpan] = {}
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def enabled(self) -> bool:
|
|
77
|
+
return bool(self._endpoint)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def trace_id(self) -> str:
|
|
81
|
+
return self._trace_id
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def span_count(self) -> int:
|
|
85
|
+
return len(self._spans)
|
|
86
|
+
|
|
87
|
+
def record_tool_start(self, tool: str) -> None:
|
|
88
|
+
"""Record the start of a tool call."""
|
|
89
|
+
if not self.enabled:
|
|
90
|
+
return
|
|
91
|
+
span = _OtelSpan(
|
|
92
|
+
trace_id=self._trace_id,
|
|
93
|
+
span_id=_generate_hex(16),
|
|
94
|
+
name=f"tool.{tool}",
|
|
95
|
+
kind=3, # CLIENT
|
|
96
|
+
start_time_ns=time.time_ns(),
|
|
97
|
+
attributes=[
|
|
98
|
+
{"key": "tool.name", "value": {"stringValue": tool}},
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
self._pending[tool] = span
|
|
102
|
+
|
|
103
|
+
def record_tool_end(self, tool: str, *, is_error: bool = False) -> None:
|
|
104
|
+
"""Record the end of a tool call."""
|
|
105
|
+
span = self._pending.pop(tool, None)
|
|
106
|
+
if span is None:
|
|
107
|
+
return
|
|
108
|
+
span.end_time_ns = time.time_ns()
|
|
109
|
+
if is_error:
|
|
110
|
+
span.status_code = 2 # ERROR
|
|
111
|
+
self._spans.append(span)
|
|
112
|
+
|
|
113
|
+
def record_run_complete(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
cost: float = 0.0,
|
|
117
|
+
tokens_total: int = 0,
|
|
118
|
+
duration_ms: float = 0,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Record the overall run as a root span."""
|
|
121
|
+
if not self.enabled:
|
|
122
|
+
return
|
|
123
|
+
now = time.time_ns()
|
|
124
|
+
self._spans.append(_OtelSpan(
|
|
125
|
+
trace_id=self._trace_id,
|
|
126
|
+
span_id=_generate_hex(16),
|
|
127
|
+
name="agent.run",
|
|
128
|
+
kind=2, # SERVER
|
|
129
|
+
start_time_ns=now - int(duration_ms * 1e6),
|
|
130
|
+
end_time_ns=now,
|
|
131
|
+
attributes=[
|
|
132
|
+
{"key": "agent.cost", "value": {"stringValue": f"{cost:.4f}"}},
|
|
133
|
+
{"key": "agent.tokens.total", "value": {"intValue": str(tokens_total)}},
|
|
134
|
+
],
|
|
135
|
+
))
|
|
136
|
+
|
|
137
|
+
async def flush(self) -> None:
|
|
138
|
+
"""Send all accumulated spans to the OTLP endpoint."""
|
|
139
|
+
if not self.enabled or not self._spans:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
spans = self._spans[:]
|
|
143
|
+
self._spans.clear()
|
|
144
|
+
|
|
145
|
+
payload = {
|
|
146
|
+
"resourceSpans": [{
|
|
147
|
+
"resource": {
|
|
148
|
+
"attributes": [
|
|
149
|
+
{"key": "service.name", "value": {"stringValue": self._service_name}},
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
"scopeSpans": [{
|
|
153
|
+
"scope": {"name": "monkeyscode-sdk-python", "version": "1.0.0"},
|
|
154
|
+
"spans": [
|
|
155
|
+
{
|
|
156
|
+
"traceId": s.trace_id,
|
|
157
|
+
"spanId": s.span_id,
|
|
158
|
+
"parentSpanId": s.parent_span_id,
|
|
159
|
+
"name": s.name,
|
|
160
|
+
"kind": s.kind,
|
|
161
|
+
"startTimeUnixNano": str(s.start_time_ns),
|
|
162
|
+
"endTimeUnixNano": str(s.end_time_ns),
|
|
163
|
+
"status": {"code": s.status_code},
|
|
164
|
+
"attributes": s.attributes,
|
|
165
|
+
}
|
|
166
|
+
for s in spans
|
|
167
|
+
],
|
|
168
|
+
}],
|
|
169
|
+
}],
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
async with httpx.AsyncClient() as client:
|
|
174
|
+
headers = {"Content-Type": "application/json", **self._headers}
|
|
175
|
+
await client.post(self._endpoint, json=payload, headers=headers, timeout=10.0)
|
|
176
|
+
except Exception:
|
|
177
|
+
pass # OTEL should never break the app
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _generate_hex(length: int) -> str:
|
|
181
|
+
return "".join(f"{random.randint(0, 255):02x}" for _ in range(length // 2))
|
monkeyscode/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|