wolf-agent 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.
- wolf_agent/__init__.py +25 -0
- wolf_agent/client.py +462 -0
- wolf_agent/errors.py +57 -0
- wolf_agent/py.typed +0 -0
- wolf_agent-0.1.0.dist-info/METADATA +91 -0
- wolf_agent-0.1.0.dist-info/RECORD +8 -0
- wolf_agent-0.1.0.dist-info/WHEEL +4 -0
- wolf_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
wolf_agent/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Thin Python client for the ``wolf mcp`` stdio server."""
|
|
2
|
+
|
|
3
|
+
from .client import CLIENT_VERSION, PROTOCOL_VERSION, WolfAgentClient
|
|
4
|
+
from .errors import (
|
|
5
|
+
ProtocolError,
|
|
6
|
+
RpcError,
|
|
7
|
+
ToolError,
|
|
8
|
+
WolfAgentError,
|
|
9
|
+
WolfProcessError,
|
|
10
|
+
WolfTimeoutError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = CLIENT_VERSION
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"PROTOCOL_VERSION",
|
|
17
|
+
"ProtocolError",
|
|
18
|
+
"RpcError",
|
|
19
|
+
"ToolError",
|
|
20
|
+
"WolfAgentClient",
|
|
21
|
+
"WolfAgentError",
|
|
22
|
+
"WolfProcessError",
|
|
23
|
+
"WolfTimeoutError",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
wolf_agent/client.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Thin stdio MCP client for ``wolf mcp``.
|
|
2
|
+
|
|
3
|
+
The client spawns ``wolf mcp --workspace-root ROOT``, speaks newline-delimited JSON-RPC 2.0
|
|
4
|
+
over the child's pipes (``initialize``, ``tools/list``, ``tools/call``), and stops the child
|
|
5
|
+
on close. It owns no tool logic: every tool argument and result is the server's own.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import collections
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import queue
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from types import TracebackType
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .errors import (
|
|
24
|
+
ProtocolError,
|
|
25
|
+
RpcError,
|
|
26
|
+
ToolError,
|
|
27
|
+
WolfAgentError,
|
|
28
|
+
WolfProcessError,
|
|
29
|
+
WolfTimeoutError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
33
|
+
CLIENT_NAME = "wolf-agent-py"
|
|
34
|
+
CLIENT_VERSION = "0.1.0"
|
|
35
|
+
# Receipts are bounded to sixteen MiB by the server; leave room for JSON framing.
|
|
36
|
+
MAX_MESSAGE_BYTES = 32 * 1024 * 1024
|
|
37
|
+
STDERR_TAIL_BYTES = 16 * 1024
|
|
38
|
+
MAX_LIST_PAGES = 64
|
|
39
|
+
|
|
40
|
+
_EOF = object()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _command(wolf: str | os.PathLike[str] | Sequence[str]) -> list[str]:
|
|
44
|
+
if isinstance(wolf, (str, os.PathLike)):
|
|
45
|
+
return [os.fspath(wolf)]
|
|
46
|
+
command = [os.fspath(part) for part in wolf]
|
|
47
|
+
if not command:
|
|
48
|
+
raise ValueError("wolf must name an executable")
|
|
49
|
+
return command
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WolfAgentClient:
|
|
53
|
+
"""One ``wolf mcp`` child process and its MCP session.
|
|
54
|
+
|
|
55
|
+
Use it as a context manager. ``wolf`` is the executable (or an argv prefix such as
|
|
56
|
+
``["python3", "server.py"]``); ``mcp --workspace-root ROOT`` is appended. ``env`` replaces
|
|
57
|
+
the child's whole environment when given. ``request_timeout`` bounds every wait for a
|
|
58
|
+
response; on expiry the process is stopped and :class:`WolfTimeoutError` is raised,
|
|
59
|
+
because the stream position is no longer known.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
workspace_root: str | os.PathLike[str],
|
|
65
|
+
*,
|
|
66
|
+
wolf: str | os.PathLike[str] | Sequence[str] = "wolf",
|
|
67
|
+
env: Mapping[str, str] | None = None,
|
|
68
|
+
startup_timeout: float = 30.0,
|
|
69
|
+
request_timeout: float = 120.0,
|
|
70
|
+
shutdown_timeout: float = 5.0,
|
|
71
|
+
) -> None:
|
|
72
|
+
for name, value in (
|
|
73
|
+
("startup_timeout", startup_timeout),
|
|
74
|
+
("request_timeout", request_timeout),
|
|
75
|
+
("shutdown_timeout", shutdown_timeout),
|
|
76
|
+
):
|
|
77
|
+
if not value > 0:
|
|
78
|
+
raise ValueError(f"{name} must be positive")
|
|
79
|
+
root = os.path.abspath(os.fspath(workspace_root))
|
|
80
|
+
if not os.path.isdir(root):
|
|
81
|
+
raise ValueError(f"workspace_root is not a directory: {root}")
|
|
82
|
+
self.workspace_root = root
|
|
83
|
+
self._argv = [*_command(wolf), "mcp", "--workspace-root", root]
|
|
84
|
+
self._env = dict(env) if env is not None else None
|
|
85
|
+
self._startup_timeout = float(startup_timeout)
|
|
86
|
+
self._request_timeout = float(request_timeout)
|
|
87
|
+
self._shutdown_timeout = float(shutdown_timeout)
|
|
88
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
89
|
+
self._lines: queue.Queue[Any] = queue.Queue()
|
|
90
|
+
self._threads: list[threading.Thread] = []
|
|
91
|
+
self._stderr: collections.deque[bytes] = collections.deque()
|
|
92
|
+
self._stderr_size = 0
|
|
93
|
+
self._stderr_lock = threading.Lock()
|
|
94
|
+
self._call_lock = threading.Lock()
|
|
95
|
+
self._next_id = 0
|
|
96
|
+
self._failure: WolfAgentError | None = None
|
|
97
|
+
self._closed = False
|
|
98
|
+
self.protocol_version: str | None = None
|
|
99
|
+
self.server_info: dict[str, Any] = {}
|
|
100
|
+
self.instructions: str | None = None
|
|
101
|
+
|
|
102
|
+
# ---- lifecycle ---------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def __enter__(self) -> WolfAgentClient:
|
|
105
|
+
self.start()
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(
|
|
109
|
+
self,
|
|
110
|
+
exc_type: type[BaseException] | None,
|
|
111
|
+
exc: BaseException | None,
|
|
112
|
+
traceback: TracebackType | None,
|
|
113
|
+
) -> None:
|
|
114
|
+
self.close()
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def returncode(self) -> int | None:
|
|
118
|
+
"""The child's exit status once it has exited, else ``None``."""
|
|
119
|
+
return None if self._proc is None else self._proc.poll()
|
|
120
|
+
|
|
121
|
+
def start(self) -> dict[str, Any]:
|
|
122
|
+
"""Spawn the server and complete the MCP handshake; return the initialize result."""
|
|
123
|
+
if self._proc is not None or self._closed:
|
|
124
|
+
raise WolfAgentError("The client was already started.")
|
|
125
|
+
try:
|
|
126
|
+
self._proc = subprocess.Popen(
|
|
127
|
+
self._argv,
|
|
128
|
+
stdin=subprocess.PIPE,
|
|
129
|
+
stdout=subprocess.PIPE,
|
|
130
|
+
stderr=subprocess.PIPE,
|
|
131
|
+
env=self._env,
|
|
132
|
+
cwd=self.workspace_root,
|
|
133
|
+
# One group per server: a stop signal also reaches any helper process the
|
|
134
|
+
# native server started (render engines), so none outlives the client.
|
|
135
|
+
start_new_session=os.name == "posix",
|
|
136
|
+
)
|
|
137
|
+
except OSError as error:
|
|
138
|
+
self._closed = True
|
|
139
|
+
raise WolfProcessError(f"Cannot start {self._argv[0]!r}: {error}") from error
|
|
140
|
+
assert self._proc.stdout is not None and self._proc.stderr is not None
|
|
141
|
+
self._threads = [
|
|
142
|
+
threading.Thread(target=self._pump_stdout, args=(self._proc.stdout,), daemon=True),
|
|
143
|
+
threading.Thread(target=self._pump_stderr, args=(self._proc.stderr,), daemon=True),
|
|
144
|
+
]
|
|
145
|
+
for thread in self._threads:
|
|
146
|
+
thread.start()
|
|
147
|
+
try:
|
|
148
|
+
result = self._request(
|
|
149
|
+
"initialize",
|
|
150
|
+
{
|
|
151
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
152
|
+
"capabilities": {},
|
|
153
|
+
"clientInfo": {"name": CLIENT_NAME, "version": CLIENT_VERSION},
|
|
154
|
+
},
|
|
155
|
+
self._startup_timeout,
|
|
156
|
+
)
|
|
157
|
+
version = result.get("protocolVersion")
|
|
158
|
+
if not isinstance(version, str) or not version:
|
|
159
|
+
raise self._fail(ProtocolError("initialize result has no protocolVersion."))
|
|
160
|
+
if not isinstance(result.get("capabilities"), dict):
|
|
161
|
+
raise self._fail(ProtocolError("initialize result has no capabilities object."))
|
|
162
|
+
info = result.get("serverInfo")
|
|
163
|
+
instructions = result.get("instructions")
|
|
164
|
+
self.protocol_version = version
|
|
165
|
+
self.server_info = info if isinstance(info, dict) else {}
|
|
166
|
+
self.instructions = instructions if isinstance(instructions, str) else None
|
|
167
|
+
self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
168
|
+
except BaseException:
|
|
169
|
+
self.close()
|
|
170
|
+
raise
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
def close(self) -> None:
|
|
174
|
+
"""Stop the child: close stdin, wait, then terminate, then kill. Idempotent."""
|
|
175
|
+
self._closed = True
|
|
176
|
+
proc = self._proc
|
|
177
|
+
if proc is None:
|
|
178
|
+
return
|
|
179
|
+
deadline = self._shutdown_timeout
|
|
180
|
+
try:
|
|
181
|
+
if proc.stdin is not None:
|
|
182
|
+
with contextlib.suppress(OSError):
|
|
183
|
+
proc.stdin.close()
|
|
184
|
+
try:
|
|
185
|
+
proc.wait(timeout=deadline)
|
|
186
|
+
except subprocess.TimeoutExpired:
|
|
187
|
+
self._stop(force=False)
|
|
188
|
+
try:
|
|
189
|
+
proc.wait(timeout=deadline)
|
|
190
|
+
except subprocess.TimeoutExpired:
|
|
191
|
+
self._stop(force=True)
|
|
192
|
+
proc.wait()
|
|
193
|
+
finally:
|
|
194
|
+
for stream in (proc.stdout, proc.stderr):
|
|
195
|
+
if stream is not None:
|
|
196
|
+
with contextlib.suppress(OSError):
|
|
197
|
+
stream.close()
|
|
198
|
+
for thread in self._threads:
|
|
199
|
+
thread.join(timeout=deadline)
|
|
200
|
+
self._threads = []
|
|
201
|
+
|
|
202
|
+
def _stop(self, *, force: bool) -> None:
|
|
203
|
+
proc = self._proc
|
|
204
|
+
assert proc is not None
|
|
205
|
+
try:
|
|
206
|
+
if os.name == "posix":
|
|
207
|
+
os.killpg(proc.pid, signal.SIGKILL if force else signal.SIGTERM)
|
|
208
|
+
elif force:
|
|
209
|
+
proc.kill()
|
|
210
|
+
else:
|
|
211
|
+
proc.terminate()
|
|
212
|
+
except (ProcessLookupError, PermissionError):
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
# ---- MCP operations ----------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def list_tools(self) -> list[dict[str, Any]]:
|
|
218
|
+
"""Return every tool descriptor (``name``, ``description``, ``inputSchema``)."""
|
|
219
|
+
tools: list[dict[str, Any]] = []
|
|
220
|
+
cursor: str | None = None
|
|
221
|
+
for _ in range(MAX_LIST_PAGES):
|
|
222
|
+
params = {} if cursor is None else {"cursor": cursor}
|
|
223
|
+
result = self._request("tools/list", params, self._request_timeout)
|
|
224
|
+
page = result.get("tools")
|
|
225
|
+
if not isinstance(page, list) or not all(isinstance(t, dict) for t in page):
|
|
226
|
+
raise self._fail(ProtocolError("tools/list result has no tools array."))
|
|
227
|
+
tools.extend(page)
|
|
228
|
+
cursor = result.get("nextCursor")
|
|
229
|
+
if cursor is None:
|
|
230
|
+
return tools
|
|
231
|
+
if not isinstance(cursor, str) or not cursor:
|
|
232
|
+
raise self._fail(ProtocolError("tools/list returned an invalid nextCursor."))
|
|
233
|
+
raise self._fail(ProtocolError(f"tools/list did not finish within {MAX_LIST_PAGES} pages."))
|
|
234
|
+
|
|
235
|
+
def call_tool(self, name: str, arguments: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
236
|
+
"""Call one tool and return its structured result.
|
|
237
|
+
|
|
238
|
+
Raises :class:`ToolError` when the server sets ``isError`` and :class:`RpcError` when
|
|
239
|
+
it answers with a JSON-RPC error.
|
|
240
|
+
"""
|
|
241
|
+
result = self._request(
|
|
242
|
+
"tools/call",
|
|
243
|
+
{"name": name, "arguments": dict(arguments or {})},
|
|
244
|
+
self._request_timeout,
|
|
245
|
+
)
|
|
246
|
+
content = result.get("content")
|
|
247
|
+
if not isinstance(content, list):
|
|
248
|
+
raise self._fail(ProtocolError(f"{name} result has no content array."))
|
|
249
|
+
is_error = result.get("isError", False)
|
|
250
|
+
if not isinstance(is_error, bool):
|
|
251
|
+
raise self._fail(ProtocolError(f"{name} result has a non-boolean isError."))
|
|
252
|
+
text = next(
|
|
253
|
+
(
|
|
254
|
+
item["text"]
|
|
255
|
+
for item in content
|
|
256
|
+
if isinstance(item, dict)
|
|
257
|
+
and item.get("type") == "text"
|
|
258
|
+
and isinstance(item.get("text"), str)
|
|
259
|
+
),
|
|
260
|
+
"",
|
|
261
|
+
)
|
|
262
|
+
structured = result.get("structuredContent")
|
|
263
|
+
if structured is not None and not isinstance(structured, dict):
|
|
264
|
+
raise self._fail(ProtocolError(f"{name} structuredContent is not an object."))
|
|
265
|
+
if structured is None and text:
|
|
266
|
+
try:
|
|
267
|
+
parsed = json.loads(text)
|
|
268
|
+
except ValueError:
|
|
269
|
+
parsed = None
|
|
270
|
+
structured = parsed if isinstance(parsed, dict) else None
|
|
271
|
+
if is_error:
|
|
272
|
+
raise ToolError(name, structured or {}, text)
|
|
273
|
+
if structured is None:
|
|
274
|
+
raise self._fail(ProtocolError(f"{name} returned no structured result."))
|
|
275
|
+
return structured
|
|
276
|
+
|
|
277
|
+
def read(self, source: str) -> dict[str, Any]:
|
|
278
|
+
"""``read``: ``revision_sha256``, compact ``html`` and anchored ``blocks``."""
|
|
279
|
+
return self.call_tool("read", {"source": source})
|
|
280
|
+
|
|
281
|
+
def search(self, source: str, query: str) -> dict[str, Any]:
|
|
282
|
+
"""``search``: the source revision and anchored ``matches`` for a literal query."""
|
|
283
|
+
return self.call_tool("search", {"source": source, "query": query})
|
|
284
|
+
|
|
285
|
+
def edit(
|
|
286
|
+
self,
|
|
287
|
+
source: str,
|
|
288
|
+
revision_sha256: str,
|
|
289
|
+
output_dir: str,
|
|
290
|
+
edits: Sequence[Mapping[str, str]],
|
|
291
|
+
*,
|
|
292
|
+
approved: bool,
|
|
293
|
+
tracking: Mapping[str, str] | None = None,
|
|
294
|
+
render: bool | None = None,
|
|
295
|
+
) -> dict[str, Any]:
|
|
296
|
+
"""``edit``: reconcile ``old_html``/``new_html`` pairs into a new DOCX in ``output_dir``."""
|
|
297
|
+
arguments: dict[str, Any] = {
|
|
298
|
+
"source": source,
|
|
299
|
+
"revision_sha256": revision_sha256,
|
|
300
|
+
"output_dir": output_dir,
|
|
301
|
+
"approved": approved,
|
|
302
|
+
"edits": [dict(pair) for pair in edits],
|
|
303
|
+
}
|
|
304
|
+
if tracking is not None:
|
|
305
|
+
arguments["tracking"] = dict(tracking)
|
|
306
|
+
if render is not None:
|
|
307
|
+
arguments["render"] = render
|
|
308
|
+
return self.call_tool("edit", arguments)
|
|
309
|
+
|
|
310
|
+
def wolf_observe(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
311
|
+
"""``wolf_observe`` with a native agent request (the tool takes the request itself)."""
|
|
312
|
+
return self.call_tool("wolf_observe", request)
|
|
313
|
+
|
|
314
|
+
def wolf_transact(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
315
|
+
"""``wolf_transact`` with a native agent request."""
|
|
316
|
+
return self.call_tool("wolf_transact", request)
|
|
317
|
+
|
|
318
|
+
def wolf_diff(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
319
|
+
"""``wolf_diff`` with a native agent request."""
|
|
320
|
+
return self.call_tool("wolf_diff", request)
|
|
321
|
+
|
|
322
|
+
def lifecycle(
|
|
323
|
+
self, phase: str, transaction: str, *, approved: bool | None = None
|
|
324
|
+
) -> dict[str, Any]:
|
|
325
|
+
"""Continue a retained transaction: ``plan``, ``apply``, ``verify``, ``attest``,
|
|
326
|
+
``commit`` (needs ``approved=True``) or ``discard``, as ``lifecycle_<phase>``."""
|
|
327
|
+
arguments: dict[str, Any] = {"transaction": transaction}
|
|
328
|
+
if approved is not None:
|
|
329
|
+
arguments["approved"] = approved
|
|
330
|
+
return self.call_tool(f"lifecycle_{phase}", arguments)
|
|
331
|
+
|
|
332
|
+
# ---- transport ---------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def _pump_stdout(self, stream: Any) -> None:
|
|
335
|
+
try:
|
|
336
|
+
while True:
|
|
337
|
+
line = stream.readline(MAX_MESSAGE_BYTES + 1)
|
|
338
|
+
if not line:
|
|
339
|
+
break
|
|
340
|
+
self._lines.put(line)
|
|
341
|
+
except (OSError, ValueError):
|
|
342
|
+
pass
|
|
343
|
+
finally:
|
|
344
|
+
self._lines.put(_EOF)
|
|
345
|
+
|
|
346
|
+
def _pump_stderr(self, stream: Any) -> None:
|
|
347
|
+
try:
|
|
348
|
+
while chunk := stream.read1(4096):
|
|
349
|
+
with self._stderr_lock:
|
|
350
|
+
self._stderr.append(chunk)
|
|
351
|
+
self._stderr_size += len(chunk)
|
|
352
|
+
while self._stderr_size > STDERR_TAIL_BYTES and len(self._stderr) > 1:
|
|
353
|
+
self._stderr_size -= len(self._stderr.popleft())
|
|
354
|
+
except (OSError, ValueError):
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
def _stderr_tail(self) -> str:
|
|
358
|
+
with self._stderr_lock:
|
|
359
|
+
data = b"".join(self._stderr)[-STDERR_TAIL_BYTES:]
|
|
360
|
+
return data.decode("utf-8", errors="replace").strip()
|
|
361
|
+
|
|
362
|
+
def _fail(self, error: WolfAgentError) -> WolfAgentError:
|
|
363
|
+
"""Record a fatal session error and stop the child; the stream cannot be trusted."""
|
|
364
|
+
if self._failure is None:
|
|
365
|
+
self._failure = error
|
|
366
|
+
self.close()
|
|
367
|
+
return error
|
|
368
|
+
|
|
369
|
+
def _exited(self, message: str) -> WolfProcessError:
|
|
370
|
+
proc = self._proc
|
|
371
|
+
returncode = None
|
|
372
|
+
if proc is not None:
|
|
373
|
+
try:
|
|
374
|
+
returncode = proc.wait(timeout=2.0)
|
|
375
|
+
except subprocess.TimeoutExpired:
|
|
376
|
+
returncode = None
|
|
377
|
+
for thread in self._threads[1:]: # let the stderr pump drain what the child wrote
|
|
378
|
+
thread.join(timeout=2.0)
|
|
379
|
+
return WolfProcessError(message, returncode=returncode, stderr=self._stderr_tail())
|
|
380
|
+
|
|
381
|
+
def _send(self, message: dict[str, Any]) -> None:
|
|
382
|
+
proc = self._proc
|
|
383
|
+
if proc is None or proc.stdin is None or proc.stdin.closed:
|
|
384
|
+
raise self._failure or WolfProcessError("The wolf mcp process is not running.")
|
|
385
|
+
data = (json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
|
|
386
|
+
try:
|
|
387
|
+
proc.stdin.write(data)
|
|
388
|
+
proc.stdin.flush()
|
|
389
|
+
except (OSError, ValueError) as error:
|
|
390
|
+
raise self._fail(self._exited("The wolf mcp process closed its input")) from error
|
|
391
|
+
|
|
392
|
+
def _request(self, method: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
393
|
+
with self._call_lock:
|
|
394
|
+
if self._failure is not None:
|
|
395
|
+
raise self._failure
|
|
396
|
+
if self._closed or self._proc is None:
|
|
397
|
+
raise WolfProcessError("The client is closed.")
|
|
398
|
+
self._next_id += 1
|
|
399
|
+
request_id = self._next_id
|
|
400
|
+
deadline = time.monotonic() + timeout
|
|
401
|
+
self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
|
|
402
|
+
while True:
|
|
403
|
+
try:
|
|
404
|
+
line = self._lines.get(timeout=max(deadline - time.monotonic(), 0.0))
|
|
405
|
+
except queue.Empty:
|
|
406
|
+
raise self._fail(
|
|
407
|
+
WolfTimeoutError(f"No response to {method} within {timeout:g} seconds.")
|
|
408
|
+
) from None
|
|
409
|
+
if line is _EOF:
|
|
410
|
+
raise self._fail(self._exited(f"The wolf mcp process ended during {method}"))
|
|
411
|
+
if len(line) > MAX_MESSAGE_BYTES:
|
|
412
|
+
raise self._fail(ProtocolError("A response exceeded the message size limit."))
|
|
413
|
+
if not line.strip():
|
|
414
|
+
continue
|
|
415
|
+
answer = self._classify(line, request_id, method)
|
|
416
|
+
if answer is not None:
|
|
417
|
+
return answer
|
|
418
|
+
|
|
419
|
+
def _classify(self, line: bytes, request_id: int, method: str) -> dict[str, Any] | None:
|
|
420
|
+
"""Return the result for ``request_id``, or ``None`` for a message to skip."""
|
|
421
|
+
try:
|
|
422
|
+
message = json.loads(line)
|
|
423
|
+
except ValueError:
|
|
424
|
+
raise self._fail(ProtocolError("The server wrote a line that is not JSON.")) from None
|
|
425
|
+
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
|
426
|
+
raise self._fail(ProtocolError("The server wrote a message that is not JSON-RPC 2.0."))
|
|
427
|
+
if "method" in message:
|
|
428
|
+
if "id" in message:
|
|
429
|
+
self._answer_server_request(message)
|
|
430
|
+
return None
|
|
431
|
+
identifier = message.get("id")
|
|
432
|
+
if type(identifier) is not int or identifier != request_id:
|
|
433
|
+
raise self._fail(
|
|
434
|
+
ProtocolError(
|
|
435
|
+
f"The response to {method} carries id {identifier!r}, not {request_id}."
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
if "error" in message:
|
|
439
|
+
error = message["error"]
|
|
440
|
+
if (
|
|
441
|
+
not isinstance(error, dict)
|
|
442
|
+
or type(error.get("code")) is not int
|
|
443
|
+
or not isinstance(error.get("message"), str)
|
|
444
|
+
):
|
|
445
|
+
raise self._fail(ProtocolError(f"The error response to {method} is malformed."))
|
|
446
|
+
raise RpcError(error["code"], error["message"], error.get("data"))
|
|
447
|
+
result = message.get("result")
|
|
448
|
+
if not isinstance(result, dict):
|
|
449
|
+
raise self._fail(ProtocolError(f"The response to {method} has no result object."))
|
|
450
|
+
return result
|
|
451
|
+
|
|
452
|
+
def _answer_server_request(self, message: dict[str, Any]) -> None:
|
|
453
|
+
if message.get("method") == "ping":
|
|
454
|
+
self._send({"jsonrpc": "2.0", "id": message["id"], "result": {}})
|
|
455
|
+
else:
|
|
456
|
+
self._send(
|
|
457
|
+
{
|
|
458
|
+
"jsonrpc": "2.0",
|
|
459
|
+
"id": message["id"],
|
|
460
|
+
"error": {"code": -32601, "message": "Method not found"},
|
|
461
|
+
}
|
|
462
|
+
)
|
wolf_agent/errors.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Exceptions raised by the wolf agent client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WolfAgentError(Exception):
|
|
9
|
+
"""Base class for every error this package raises about the MCP session."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WolfProcessError(WolfAgentError):
|
|
13
|
+
"""The ``wolf mcp`` process could not start, exited, or closed its pipes."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, *, returncode: int | None = None, stderr: str = "") -> None:
|
|
16
|
+
detail = f"{message} (exit status {returncode})" if returncode is not None else message
|
|
17
|
+
if stderr:
|
|
18
|
+
detail = f"{detail}: {stderr}"
|
|
19
|
+
super().__init__(detail)
|
|
20
|
+
self.returncode = returncode
|
|
21
|
+
self.stderr = stderr
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WolfTimeoutError(WolfAgentError, TimeoutError):
|
|
25
|
+
"""The server did not answer inside the bounded wait; the process was stopped."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProtocolError(WolfAgentError):
|
|
29
|
+
"""The server broke the newline-delimited JSON-RPC contract."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RpcError(WolfAgentError):
|
|
33
|
+
"""The server answered a request with a JSON-RPC error object."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, code: int, message: str, data: Any = None) -> None:
|
|
36
|
+
super().__init__(f"JSON-RPC error {code}: {message}")
|
|
37
|
+
self.code = code
|
|
38
|
+
self.message = message
|
|
39
|
+
self.data = data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ToolError(WolfAgentError):
|
|
43
|
+
"""A tool call completed with ``isError`` set: a typed refusal or a failed engine.
|
|
44
|
+
|
|
45
|
+
``document`` is the structured result (for example an ``edit`` refusal with ``code``,
|
|
46
|
+
``message`` and ``hint``), or ``{}`` when the server returned only text.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, tool: str, document: dict[str, Any], text: str) -> None:
|
|
50
|
+
self.tool = tool
|
|
51
|
+
self.document = document
|
|
52
|
+
self.text = text
|
|
53
|
+
self.code = document.get("code") if isinstance(document.get("code"), str) else None
|
|
54
|
+
self.hint = document.get("hint") if isinstance(document.get("hint"), str) else None
|
|
55
|
+
message = document.get("message")
|
|
56
|
+
summary = message if isinstance(message, str) else text
|
|
57
|
+
super().__init__(f"{tool} refused{f' ({self.code})' if self.code else ''}: {summary}")
|
wolf_agent/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: wolf-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the wolf mcp server: offline Office document tools for AI agents
|
|
5
|
+
Project-URL: Homepage, https://wolfsdk.com
|
|
6
|
+
Project-URL: Documentation, https://wolfsdk.com/runtime/docs#integrations
|
|
7
|
+
Author: Wolfgang Schoenberger
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,docx,mcp,model-context-protocol,office,pdf,pptx,xlsx
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Office/Business :: Office Suites
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# wolf-agent (Python)
|
|
23
|
+
|
|
24
|
+
A thin client for the `wolf mcp` stdio server. `wolf` itself is a native executable: it needs no
|
|
25
|
+
Python, no pip packages and no network. This client uses only the Python standard library
|
|
26
|
+
(Python 3.11 or newer), opens no sockets, and holds no tool logic: it spawns
|
|
27
|
+
`wolf mcp --workspace-root ROOT`, speaks newline-delimited JSON-RPC 2.0 (`initialize`,
|
|
28
|
+
`notifications/initialized`, `tools/list`, `tools/call`), and stops the child on close.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install wolf-agent
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The `wolf` executable ships with Wolf Runtime. Start a free evaluation at
|
|
37
|
+
<https://wolfxl.com/evaluate>, then run `wolf mcp --help` to check that your build includes the
|
|
38
|
+
server. MCP clients that launch servers by command can also run `wolf mcp --workspace-root ROOT`
|
|
39
|
+
directly, without this package.
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from wolf_agent import WolfAgentClient, ToolError
|
|
45
|
+
|
|
46
|
+
with WolfAgentClient("/abs/workspace", wolf="/path/to/wolf") as wolf:
|
|
47
|
+
read = wolf.read("report.docx") # revision_sha256, html, blocks
|
|
48
|
+
hits = wolf.search("report.docx", "Quarterly") # anchored matches
|
|
49
|
+
old = hits["matches"][0]["html"]
|
|
50
|
+
try:
|
|
51
|
+
receipt = wolf.edit(
|
|
52
|
+
"report.docx",
|
|
53
|
+
read["revision_sha256"],
|
|
54
|
+
"revised",
|
|
55
|
+
[{"old_html": old, "new_html": old.replace("Quarterly", "Annual")}],
|
|
56
|
+
approved=True,
|
|
57
|
+
)
|
|
58
|
+
except ToolError as refusal: # isError: typed refusal
|
|
59
|
+
print(refusal.code, refusal.hint)
|
|
60
|
+
else:
|
|
61
|
+
print(receipt["status"], receipt["published"])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`wolf_observe`, `wolf_transact` and `wolf_diff` take the native agent request directly
|
|
65
|
+
(`wolf.wolf_observe(request)`); `wolf.lifecycle(phase, transaction, approved=None)` continues a
|
|
66
|
+
server-owned transaction (`lifecycle_plan` through `lifecycle_discard`; commit needs
|
|
67
|
+
`approved=True`); `call_tool(name, arguments)` reaches any tool and `list_tools()` returns the
|
|
68
|
+
server's descriptors. Each result is the tool's `structuredContent`.
|
|
69
|
+
|
|
70
|
+
## Behaviour
|
|
71
|
+
|
|
72
|
+
| Condition | Result |
|
|
73
|
+
|---|---|
|
|
74
|
+
| Tool result with `isError` | `ToolError` with `.tool`, `.code`, `.hint`, `.document`; the session stays usable |
|
|
75
|
+
| JSON-RPC error object | `RpcError` with `.code`, `.message`, `.data`; the session stays usable |
|
|
76
|
+
| Non-JSON line, wrong response id, malformed result | `ProtocolError`; the child is stopped and later calls re-raise it |
|
|
77
|
+
| No response within `request_timeout` (default 120 s; `startup_timeout` 30 s) | `WolfTimeoutError`; the child is stopped |
|
|
78
|
+
| Spawn failure, exit or closed pipes | `WolfProcessError` with `.returncode` and the stderr tail |
|
|
79
|
+
|
|
80
|
+
`close()` (and leaving the `with` block) closes stdin, waits `shutdown_timeout` (5 s), then
|
|
81
|
+
terminates, then kills. `wolf` may also be an argv prefix, for example
|
|
82
|
+
`["docker", "run", "-i", "--rm", "--network", "none", ...]`; `env` replaces the child's whole
|
|
83
|
+
environment.
|
|
84
|
+
|
|
85
|
+
## Tests
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
python3 -m pytest tests
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The tests drive a scripted stand-in server and need only `pytest`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
wolf_agent/__init__.py,sha256=--Vv93XysD6VpRFA9_vOGL3uMKHBKefAJVyctdAk7pE,502
|
|
2
|
+
wolf_agent/client.py,sha256=uy5wba_PZLSTL_emmu1fCre4D28eBvSG7GngO3E9yiM,19138
|
|
3
|
+
wolf_agent/errors.py,sha256=rxQzbjVJAUCw1w-ds_bSwGf08DpnrnQsxoduMkW5XHQ,2123
|
|
4
|
+
wolf_agent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
wolf_agent-0.1.0.dist-info/METADATA,sha256=4Gey5VjpEwkCGxmlbCpPNfyTJALntjB0ssfooRppljA,3796
|
|
6
|
+
wolf_agent-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
wolf_agent-0.1.0.dist-info/licenses/LICENSE,sha256=JKzPXq3f7o1e4rX_LdIS1izSmmLRlPsMW8aBssenlzA,1078
|
|
8
|
+
wolf_agent-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wolfgang Schoenberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|