wolf-agent 0.1.0__tar.gz

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.
@@ -0,0 +1,15 @@
1
+ .DS_Store
2
+ .venv/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ *.py[cod]
7
+ *.egg-info/
8
+ build/
9
+ dist/
10
+ .coverage
11
+ htmlcov/
12
+ .wolfsuite/
13
+ .commercial-integration/
14
+ # Cargo workspace build output (root Cargo.toml); maturin gates write here.
15
+ /target/
@@ -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.
@@ -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,70 @@
1
+ # wolf-agent (Python)
2
+
3
+ A thin client for the `wolf mcp` stdio server. `wolf` itself is a native executable: it needs no
4
+ Python, no pip packages and no network. This client uses only the Python standard library
5
+ (Python 3.11 or newer), opens no sockets, and holds no tool logic: it spawns
6
+ `wolf mcp --workspace-root ROOT`, speaks newline-delimited JSON-RPC 2.0 (`initialize`,
7
+ `notifications/initialized`, `tools/list`, `tools/call`), and stops the child on close.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pip install wolf-agent
13
+ ```
14
+
15
+ The `wolf` executable ships with Wolf Runtime. Start a free evaluation at
16
+ <https://wolfxl.com/evaluate>, then run `wolf mcp --help` to check that your build includes the
17
+ server. MCP clients that launch servers by command can also run `wolf mcp --workspace-root ROOT`
18
+ directly, without this package.
19
+
20
+ ## Use
21
+
22
+ ```python
23
+ from wolf_agent import WolfAgentClient, ToolError
24
+
25
+ with WolfAgentClient("/abs/workspace", wolf="/path/to/wolf") as wolf:
26
+ read = wolf.read("report.docx") # revision_sha256, html, blocks
27
+ hits = wolf.search("report.docx", "Quarterly") # anchored matches
28
+ old = hits["matches"][0]["html"]
29
+ try:
30
+ receipt = wolf.edit(
31
+ "report.docx",
32
+ read["revision_sha256"],
33
+ "revised",
34
+ [{"old_html": old, "new_html": old.replace("Quarterly", "Annual")}],
35
+ approved=True,
36
+ )
37
+ except ToolError as refusal: # isError: typed refusal
38
+ print(refusal.code, refusal.hint)
39
+ else:
40
+ print(receipt["status"], receipt["published"])
41
+ ```
42
+
43
+ `wolf_observe`, `wolf_transact` and `wolf_diff` take the native agent request directly
44
+ (`wolf.wolf_observe(request)`); `wolf.lifecycle(phase, transaction, approved=None)` continues a
45
+ server-owned transaction (`lifecycle_plan` through `lifecycle_discard`; commit needs
46
+ `approved=True`); `call_tool(name, arguments)` reaches any tool and `list_tools()` returns the
47
+ server's descriptors. Each result is the tool's `structuredContent`.
48
+
49
+ ## Behaviour
50
+
51
+ | Condition | Result |
52
+ |---|---|
53
+ | Tool result with `isError` | `ToolError` with `.tool`, `.code`, `.hint`, `.document`; the session stays usable |
54
+ | JSON-RPC error object | `RpcError` with `.code`, `.message`, `.data`; the session stays usable |
55
+ | Non-JSON line, wrong response id, malformed result | `ProtocolError`; the child is stopped and later calls re-raise it |
56
+ | No response within `request_timeout` (default 120 s; `startup_timeout` 30 s) | `WolfTimeoutError`; the child is stopped |
57
+ | Spawn failure, exit or closed pipes | `WolfProcessError` with `.returncode` and the stderr tail |
58
+
59
+ `close()` (and leaving the `with` block) closes stdin, waits `shutdown_timeout` (5 s), then
60
+ terminates, then kills. `wolf` may also be an argv prefix, for example
61
+ `["docker", "run", "-i", "--rm", "--network", "none", ...]`; `env` replaces the child's whole
62
+ environment.
63
+
64
+ ## Tests
65
+
66
+ ```sh
67
+ python3 -m pytest tests
68
+ ```
69
+
70
+ The tests drive a scripted stand-in server and need only `pytest`.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wolf-agent"
7
+ version = "0.1.0"
8
+ description = "Python client for the wolf mcp server: offline Office document tools for AI agents"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Wolfgang Schoenberger" }]
14
+ keywords = ["mcp", "model-context-protocol", "agents", "docx", "xlsx", "pptx", "pdf", "office"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: MacOS",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Topic :: Office/Business :: Office Suites",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Homepage = "https://wolfsdk.com"
29
+ Documentation = "https://wolfsdk.com/runtime/docs#integrations"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/wolf_agent"]
33
+
34
+ [tool.pytest.ini_options]
35
+ addopts = "-q"
36
+ testpaths = ["tests"]
37
+ pythonpath = ["src"]
@@ -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
+ ]
@@ -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
+ )
@@ -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}")
File without changes
@@ -0,0 +1,126 @@
1
+ """A scripted stand-in for ``wolf mcp`` used only by the SDK protocol tests.
2
+
3
+ Run as ``python fake_wolf.py mcp --workspace-root ROOT``. ``FAKE_WOLF_MODE`` selects the
4
+ behaviour; ``FAKE_WOLF_PIDFILE`` receives the server's process id so a test can prove the
5
+ process is gone after the client closes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import signal
13
+ import sys
14
+ import time
15
+
16
+ MODE = os.environ.get("FAKE_WOLF_MODE", "ok")
17
+
18
+
19
+ def send(message: dict) -> None:
20
+ sys.stdout.write(json.dumps(message) + "\n")
21
+ sys.stdout.flush()
22
+
23
+
24
+ def reply(message: dict, result: dict) -> None:
25
+ send({"jsonrpc": "2.0", "id": message["id"], "result": result})
26
+
27
+
28
+ def tool_result(document: dict, *, error: bool = False) -> dict:
29
+ return {
30
+ "content": [{"type": "text", "text": json.dumps(document)}],
31
+ "structuredContent": document,
32
+ "isError": error,
33
+ }
34
+
35
+
36
+ def main() -> int:
37
+ if pidfile := os.environ.get("FAKE_WOLF_PIDFILE"):
38
+ with open(pidfile, "w", encoding="utf-8") as handle:
39
+ handle.write(str(os.getpid()))
40
+ if MODE == "exit_before_init":
41
+ sys.stderr.write("boom: cannot open workspace\n")
42
+ return 3
43
+ if MODE == "stubborn":
44
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
45
+ for raw in sys.stdin:
46
+ message = json.loads(raw)
47
+ method = message.get("method")
48
+ if method == "initialize":
49
+ reply(
50
+ message,
51
+ {
52
+ "protocolVersion": message["params"]["protocolVersion"],
53
+ "capabilities": {"tools": {}},
54
+ "serverInfo": {"name": "fake-wolf", "version": "0"},
55
+ "instructions": "fake",
56
+ },
57
+ )
58
+ elif method == "notifications/initialized":
59
+ continue
60
+ elif method == "tools/list":
61
+ if MODE == "garbage":
62
+ sys.stdout.write("this is not json\n")
63
+ sys.stdout.flush()
64
+ continue
65
+ if MODE == "wrong_id":
66
+ send({"jsonrpc": "2.0", "id": message["id"] + 100, "result": {"tools": []}})
67
+ continue
68
+ cursor = message["params"].get("cursor")
69
+ if cursor is None:
70
+ reply(message, {"tools": [{"name": "read"}, {"name": "search"}], "nextCursor": "2"})
71
+ else:
72
+ reply(message, {"tools": [{"name": "edit"}]})
73
+ elif method == "tools/call":
74
+ if MODE in ("hang", "stubborn"):
75
+ time.sleep(3600)
76
+ handle_call(message)
77
+ else:
78
+ send(
79
+ {
80
+ "jsonrpc": "2.0",
81
+ "id": message["id"],
82
+ "error": {"code": -32601, "message": "Method not found"},
83
+ }
84
+ )
85
+ if MODE == "stubborn":
86
+ time.sleep(3600)
87
+ return 0
88
+
89
+
90
+ def handle_call(message: dict) -> None:
91
+ name = message["params"]["name"]
92
+ arguments = message["params"]["arguments"]
93
+ # A notification and a server-initiated ping must not disturb the pending call.
94
+ send({"jsonrpc": "2.0", "method": "notifications/message", "params": {"level": "info"}})
95
+ send({"jsonrpc": "2.0", "id": "srv-1", "method": "ping"})
96
+ pong = json.loads(sys.stdin.readline())
97
+ if pong != {"jsonrpc": "2.0", "id": "srv-1", "result": {}}:
98
+ sys.exit(9)
99
+ if name == "refuse":
100
+ document = {
101
+ "status": "refused",
102
+ "code": "stale-revision",
103
+ "message": "The source changed after read.",
104
+ "hint": "Read the current source.",
105
+ }
106
+ reply(message, tool_result(document, error=True))
107
+ elif name == "rpc_error":
108
+ send(
109
+ {
110
+ "jsonrpc": "2.0",
111
+ "id": message["id"],
112
+ "error": {"code": -32602, "message": "Invalid params", "data": {"at": "x"}},
113
+ }
114
+ )
115
+ elif name == "text_only":
116
+ reply(message, {"content": [{"type": "text", "text": '{"n": 1}'}], "isError": False})
117
+ elif name == "prose":
118
+ reply(message, {"content": [{"type": "text", "text": "plain prose"}], "isError": False})
119
+ elif name == "text_error":
120
+ reply(message, {"content": [{"type": "text", "text": "no-such: gone"}], "isError": True})
121
+ else:
122
+ reply(message, tool_result({"tool": name, "arguments": arguments}))
123
+
124
+
125
+ if __name__ == "__main__":
126
+ sys.exit(main())
@@ -0,0 +1,140 @@
1
+ """Consumer-visible protocol errors and process lifetime of the wolf agent client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+ from wolf_agent import (
12
+ ProtocolError,
13
+ RpcError,
14
+ ToolError,
15
+ WolfAgentClient,
16
+ WolfProcessError,
17
+ WolfTimeoutError,
18
+ )
19
+
20
+ FAKE = Path(__file__).with_name("fake_wolf.py")
21
+
22
+
23
+ def client(tmp_path: Path, mode: str = "ok", **options) -> WolfAgentClient:
24
+ env = {**os.environ, "FAKE_WOLF_MODE": mode, "FAKE_WOLF_PIDFILE": str(tmp_path / "pid")}
25
+ return WolfAgentClient(tmp_path, wolf=[sys.executable, str(FAKE)], env=env, **options)
26
+
27
+
28
+ def pid_of(tmp_path: Path) -> int:
29
+ return int((tmp_path / "pid").read_text())
30
+
31
+
32
+ def alive(pid: int) -> bool:
33
+ try:
34
+ os.kill(pid, 0)
35
+ except ProcessLookupError:
36
+ return False
37
+ return True
38
+
39
+
40
+ def test_is_error_result_raises_a_typed_refusal_and_keeps_the_session(tmp_path: Path) -> None:
41
+ with client(tmp_path) as wolf:
42
+ with pytest.raises(ToolError) as refused:
43
+ wolf.call_tool("refuse")
44
+ assert refused.value.tool == "refuse"
45
+ assert refused.value.code == "stale-revision"
46
+ assert refused.value.hint == "Read the current source."
47
+ assert refused.value.document["status"] == "refused"
48
+ assert wolf.call_tool("later") == {"tool": "later", "arguments": {}}
49
+ with pytest.raises(ToolError) as bare:
50
+ wolf.call_tool("text_error")
51
+ assert bare.value.code is None
52
+ assert bare.value.document == {}
53
+ assert "no-such: gone" in str(bare.value)
54
+
55
+
56
+ def test_json_rpc_error_raises_rpc_error_without_ending_the_session(tmp_path: Path) -> None:
57
+ with client(tmp_path) as wolf:
58
+ with pytest.raises(RpcError) as error:
59
+ wolf.call_tool("rpc_error")
60
+ assert (error.value.code, error.value.message) == (-32602, "Invalid params")
61
+ assert error.value.data == {"at": "x"}
62
+ assert wolf.call_tool("after")["tool"] == "after"
63
+
64
+
65
+ def test_text_json_is_used_when_structured_content_is_absent(tmp_path: Path) -> None:
66
+ with client(tmp_path) as wolf:
67
+ assert wolf.call_tool("text_only") == {"n": 1}
68
+ with pytest.raises(ProtocolError):
69
+ wolf.call_tool("prose")
70
+ assert wolf.returncode is not None
71
+
72
+
73
+ def test_garbage_output_is_a_protocol_error_and_stops_the_process(tmp_path: Path) -> None:
74
+ wolf = client(tmp_path, "garbage")
75
+ wolf.start()
76
+ with pytest.raises(ProtocolError, match="not JSON"):
77
+ wolf.list_tools()
78
+ assert wolf.returncode is not None
79
+ assert not alive(pid_of(tmp_path))
80
+ with pytest.raises(ProtocolError):
81
+ wolf.list_tools()
82
+
83
+
84
+ def test_response_with_the_wrong_id_is_a_protocol_error(tmp_path: Path) -> None:
85
+ wolf = client(tmp_path, "wrong_id")
86
+ wolf.start()
87
+ with pytest.raises(ProtocolError, match="carries id"):
88
+ wolf.list_tools()
89
+ assert not alive(pid_of(tmp_path))
90
+
91
+
92
+ def test_process_exit_before_initialize_reports_status_and_stderr(tmp_path: Path) -> None:
93
+ with pytest.raises(WolfProcessError) as failed:
94
+ client(tmp_path, "exit_before_init").start()
95
+ assert failed.value.returncode == 3
96
+ assert "boom: cannot open workspace" in failed.value.stderr
97
+
98
+
99
+ def test_missing_executable_is_a_process_error(tmp_path: Path) -> None:
100
+ with pytest.raises(WolfProcessError, match="Cannot start"):
101
+ WolfAgentClient(tmp_path, wolf=str(tmp_path / "no-such-wolf")).start()
102
+
103
+
104
+ def test_unanswered_request_times_out_and_the_process_is_stopped(tmp_path: Path) -> None:
105
+ wolf = client(tmp_path, "hang", request_timeout=0.4, shutdown_timeout=1.0)
106
+ wolf.start()
107
+ began = time.monotonic()
108
+ with pytest.raises(WolfTimeoutError):
109
+ wolf.call_tool("read", {"source": "a.docx"})
110
+ assert time.monotonic() - began < 10
111
+ assert wolf.returncode is not None
112
+ assert not alive(pid_of(tmp_path))
113
+ with pytest.raises(WolfTimeoutError):
114
+ wolf.call_tool("read", {"source": "a.docx"})
115
+
116
+
117
+ def test_close_escalates_to_kill_when_the_server_ignores_eof_and_sigterm(tmp_path: Path) -> None:
118
+ wolf = client(tmp_path, "stubborn", shutdown_timeout=0.3)
119
+ wolf.start()
120
+ began = time.monotonic()
121
+ wolf.close()
122
+ assert time.monotonic() - began < 10
123
+ assert not alive(pid_of(tmp_path))
124
+ wolf.close()
125
+
126
+
127
+ def test_context_exit_lets_a_well_behaved_server_exit_cleanly(tmp_path: Path) -> None:
128
+ with client(tmp_path) as wolf:
129
+ wolf.list_tools()
130
+ assert wolf.returncode == 0
131
+ assert not alive(pid_of(tmp_path))
132
+ with pytest.raises(WolfProcessError, match="closed"):
133
+ wolf.list_tools()
134
+
135
+
136
+ def test_workspace_root_must_be_an_existing_directory(tmp_path: Path) -> None:
137
+ with pytest.raises(ValueError, match="not a directory"):
138
+ WolfAgentClient(tmp_path / "missing")
139
+ with pytest.raises(ValueError, match="positive"):
140
+ WolfAgentClient(tmp_path, request_timeout=0)