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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.