xiaoyu-agent-sdk 0.58.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fenghuang
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,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: xiaoyu-agent-sdk
3
+ Version: 0.58.0
4
+ Summary: In-process Python SDK for the Xiaoyu workspace execution engine
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/pholex/zhinu
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: xiaoyu-agent[sdk]==0.58.0
11
+ Dynamic: license-file
12
+ Dynamic: requires-dist
13
+
14
+ # Xiaoyu Agent SDK
15
+
16
+ Python 3.11+ host API for the Xiaoyu workspace execution engine. The SDK runs in
17
+ your process and depends on the exact same version of `xiaoyu-agent`.
18
+
19
+ ```python
20
+ from pathlib import Path
21
+ from xiaoyu_agent_sdk import ModelOptions, SessionOptions, run
22
+
23
+ result = run("Explain this project", SessionOptions(
24
+ model=ModelOptions(model="your-model", api_key="your-key"),
25
+ workspace=Path.cwd(),
26
+ ))
27
+ print(result.text)
28
+ ```
29
+
30
+ Model credentials are explicit. Mutating tools require host approval by default.
31
+ Use `Session` for multiple turns, `AsyncSession` for async applications, and
32
+ `OutputSpec` for validated JSON results with bounded repair attempts.
33
+
34
+ See [SDK guide](https://github.com/pholex/zhinu/blob/main/docs/sdk.md) for ownership,
35
+ approval, streaming, structured output, extension and persistence contracts.
@@ -0,0 +1,22 @@
1
+ # Xiaoyu Agent SDK
2
+
3
+ Python 3.11+ host API for the Xiaoyu workspace execution engine. The SDK runs in
4
+ your process and depends on the exact same version of `xiaoyu-agent`.
5
+
6
+ ```python
7
+ from pathlib import Path
8
+ from xiaoyu_agent_sdk import ModelOptions, SessionOptions, run
9
+
10
+ result = run("Explain this project", SessionOptions(
11
+ model=ModelOptions(model="your-model", api_key="your-key"),
12
+ workspace=Path.cwd(),
13
+ ))
14
+ print(result.text)
15
+ ```
16
+
17
+ Model credentials are explicit. Mutating tools require host approval by default.
18
+ Use `Session` for multiple turns, `AsyncSession` for async applications, and
19
+ `OutputSpec` for validated JSON results with bounded repair attempts.
20
+
21
+ See [SDK guide](https://github.com/pholex/zhinu/blob/main/docs/sdk.md) for ownership,
22
+ approval, streaming, structured output, extension and persistence contracts.
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xiaoyu-agent-sdk"
7
+ dynamic = ["version", "dependencies"]
8
+ description = "In-process Python SDK for the Xiaoyu workspace execution engine"
9
+ requires-python = ">=3.11"
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/pholex/zhinu"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.setuptools.package-data]
21
+ xiaoyu_agent_sdk = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ """Generate both metadata fields from the kernel version; sdist is standalone."""
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from setuptools import setup
6
+
7
+ here = Path(__file__).resolve().parent
8
+ source = here.parents[1] / "xiaoyu" / "__init__.py"
9
+ generated = here / "src" / "xiaoyu_agent_sdk" / "_version.py"
10
+ if source.is_file():
11
+ version = re.search(r'^__version__ = "([^"]+)"', source.read_text(encoding="utf-8"), re.M).group(1)
12
+ generated.write_text(f'__version__ = "{version}"\n', encoding="utf-8")
13
+ else:
14
+ version = re.search(r'^__version__ = "([^"]+)"', generated.read_text(encoding="utf-8"), re.M).group(1)
15
+ setup(version=version, install_requires=[f"xiaoyu-agent[sdk]=={version}"])
@@ -0,0 +1,38 @@
1
+ """Stable host API for Xiaoyu's in-process workspace execution engine."""
2
+ from ._version import __version__ as __version__
3
+ from .session import AsyncSession as AsyncSession, Session as Session, run as run, run_async as run_async
4
+ from .session import list_sessions as list_sessions
5
+ from .types import Plugin as Plugin, McpServerStatus as McpServerStatus
6
+ from xiaoyu.rewind import RewindResult as RewindResult
7
+ from .types import (
8
+ Allow as Allow, Approval as Approval, Approver as Approver, Deny as Deny,
9
+ CloseTimeoutError as CloseTimeoutError, ConfigurationError as ConfigurationError,
10
+ ExecutionError as ExecutionError, Hook as Hook, HookDecision as HookDecision,
11
+ HookHandler as HookHandler, McpServer as McpServer, ModelOptions as ModelOptions,
12
+ OutputSpec as OutputSpec, SDKError as SDKError, SessionBusyError as SessionBusyError,
13
+ SessionClosedError as SessionClosedError, SessionOptions as SessionOptions,
14
+ SessionStorageError as SessionStorageError, Subagent as Subagent, Tool as Tool,
15
+ ToolHandler as ToolHandler, ToolResult as ToolResult,
16
+ )
17
+ from xiaoyu.embedding import RunCompleted as RunCompleted, RunResult as RunResult
18
+ from xiaoyu.events import (
19
+ Notice as Notice, RequestEnded as RequestEnded, RequestStarted as RequestStarted,
20
+ TextDelta as TextDelta, TextEnd as TextEnd, ToolCompleted as ToolCompleted,
21
+ ToolDenied as ToolDenied, ToolPending as ToolPending, ToolRunning as ToolRunning,
22
+ UIEvent as UIEvent,
23
+ )
24
+ from xiaoyu.output import OutputSchemaError as OutputSchemaError
25
+ from xiaoyu.session_log import SessionLockedError as SessionLockedError
26
+ from xiaoyu.session_log import SessionInfo as SessionInfo
27
+
28
+ __all__ = [
29
+ "Plugin", "McpServerStatus", "RewindResult",
30
+ "__version__", "Session", "AsyncSession", "run", "run_async", "ModelOptions",
31
+ "SessionOptions", "OutputSpec", "Tool", "ToolResult", "ToolHandler", "Hook",
32
+ "HookHandler", "HookDecision", "McpServer", "Subagent", "Approval", "Approver",
33
+ "Allow", "Deny", "RunResult", "RunCompleted", "UIEvent", "Notice", "TextDelta",
34
+ "TextEnd", "RequestStarted", "RequestEnded", "ToolPending", "ToolRunning",
35
+ "ToolCompleted", "ToolDenied", "SDKError", "ConfigurationError", "ExecutionError",
36
+ "CloseTimeoutError", "SessionBusyError", "SessionClosedError", "SessionStorageError",
37
+ "SessionLockedError", "SessionInfo", "list_sessions", "OutputSchemaError",
38
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.58.0"
File without changes
@@ -0,0 +1,643 @@
1
+ """Session ownership and host adapters; execution stays in xiaoyu.Agent."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import copy
6
+ import inspect
7
+ import json
8
+ import math
9
+ import queue
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from concurrent.futures import Future, InvalidStateError, ThreadPoolExecutor, TimeoutError as FutureTimeout
15
+ from dataclasses import replace
16
+ from pathlib import Path
17
+ from typing import Any, AsyncGenerator, Generator
18
+
19
+ from xiaoyu.agent import Agent, Deny, Interrupted
20
+ from xiaoyu.config import Config
21
+ from xiaoyu.embedding import RunCompleted, RunResult, measured_send
22
+ from xiaoyu.events import UIEvent
23
+ from xiaoyu.hooks import Decision
24
+ from xiaoyu.output import OutputContract, validator_for
25
+ from xiaoyu.permissions import Permissions, parse_rule
26
+ from xiaoyu.providers import Provider, Registry
27
+ from xiaoyu.rewind import RewindResult
28
+ from xiaoyu.session_log import SessionInfo, SessionLog, load_messages, list_sessions as _list_sessions
29
+ from xiaoyu.tools import Tool as KernelTool, Toolbox
30
+
31
+ from .types import (
32
+ CloseTimeoutError, ConfigurationError, ExecutionError, OutputSpec,
33
+ SessionBusyError, SessionClosedError, SessionOptions, SessionStorageError,
34
+ ToolResult, McpServerStatus,
35
+ )
36
+
37
+
38
+ class _Sink:
39
+ def __init__(self, session: Session) -> None:
40
+ self.session = session
41
+
42
+ def emit(self, event: UIEvent) -> None:
43
+ events = self.session._events
44
+ if events is None:
45
+ return
46
+ while not self.session._cancel.is_set():
47
+ try:
48
+ events.put(event, timeout=0.05)
49
+ return
50
+ except queue.Full:
51
+ pass
52
+
53
+
54
+ class _Hooks:
55
+ def __init__(self, session: Session, hooks: tuple) -> None:
56
+ self.session, self.hooks = session, hooks
57
+
58
+ def has(self, event: str) -> bool:
59
+ return any(h.event == event for h in self.hooks)
60
+
61
+ def for_tools(self, workspace: Path) -> _Hooks:
62
+ return _Hooks(self.session, tuple(h for h in self.hooks if h.event in ("PreToolUse", "PostToolUse")))
63
+
64
+ def fire(self, event: str, payload: dict, tool_name: str = "", also: str = "") -> Decision:
65
+ for hook in self.hooks:
66
+ if hook.event != event or hook.tool_name and hook.tool_name not in (tool_name, also):
67
+ continue
68
+ try:
69
+ result = self.session._callback(hook.callback, {"event": event, **copy.deepcopy(payload)})
70
+ if not isinstance(result, Decision):
71
+ raise TypeError("Hook must return HookDecision")
72
+ except Interrupted:
73
+ raise
74
+ except Exception:
75
+ return Decision(True, "Host hook failed")
76
+ if result.blocked:
77
+ return result
78
+ return Decision(False)
79
+
80
+
81
+ class Session:
82
+ """One serial conversation and its owned tools, log, transport and worker.
83
+
84
+ Concurrent submissions fail with SessionBusyError. Use distinct instances
85
+ for parallel conversations. close() is idempotent; a timeout leaves the
86
+ session closing, and close() may be retried.
87
+ """
88
+
89
+ def __init__(self, options: SessionOptions, *, resume_from: Path | None = None) -> None:
90
+ self._mutex = threading.RLock()
91
+ self._close_mutex = threading.Lock()
92
+ self._cancel = threading.Event()
93
+ self._future: Future | None = None
94
+ self._cleanup_future: Future | None = None
95
+ self._closing = False
96
+ self._closed = False
97
+ self._events: queue.Queue | None = None
98
+ self._loop: asyncio.AbstractEventLoop | None = None
99
+ self._callbacks: set[Future] = set()
100
+ self._async_settling: set[threading.Event] = set()
101
+ self._stream_active = False
102
+ self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="xiaoyu-session")
103
+ self._callback_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="xiaoyu-host")
104
+ self._owned_clients: list[Any] = []
105
+ self._log: SessionLog | None = None
106
+ self._mcp: Any = None
107
+ self._mcp_directory: tempfile.TemporaryDirectory | None = None
108
+ self._agent: Agent | None = None
109
+ self._toolbox: Toolbox | None = None
110
+ # Callables and borrowed clients retain identity; mutable configuration does not.
111
+ self.options = replace(
112
+ options, workspace=Path(options.workspace).resolve(),
113
+ tool_env=dict(options.tool_env),
114
+ tools=tuple(replace(t, parameters=copy.deepcopy(t.parameters)) for t in options.tools),
115
+ mcp_servers=tuple(replace(s, env=dict(s.env), headers=dict(s.headers)) for s in options.mcp_servers),
116
+ )
117
+ try:
118
+ self._build(resume_from)
119
+ except BaseException:
120
+ self.close()
121
+ raise
122
+
123
+ def _build(self, resume_from: Path | None) -> None:
124
+ options, model = self.options, self.options.model
125
+ if not options.workspace.is_dir():
126
+ raise ConfigurationError("workspace must be an existing directory")
127
+ if not model.model or model.protocol not in ("chat", "responses", "anthropic"):
128
+ raise ConfigurationError("A model and supported protocol are required")
129
+ for value in (model.request_timeout, options.close_timeout, options.approval_timeout):
130
+ if not math.isfinite(value) or value <= 0:
131
+ raise ConfigurationError("Timeouts must be positive")
132
+ if any(type(value) is not int or value < 1 for value in (options.max_iterations, options.event_buffer_size)):
133
+ raise ConfigurationError("Iteration and event buffer limits must be positive")
134
+ if options.budget_tokens is not None and (type(options.budget_tokens) is not int or options.budget_tokens < 1):
135
+ raise ConfigurationError("budget_tokens must be a positive integer")
136
+ if any(bool(s.command) == bool(s.url) or not s.name or not math.isfinite(s.timeout) or s.timeout <= 0
137
+ for s in options.mcp_servers):
138
+ raise ConfigurationError("MCP requires a name, positive timeout and exactly one of command/url")
139
+ if any(h.event not in ("PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop") for h in options.hooks):
140
+ raise ConfigurationError("Unsupported hook event")
141
+ if any(s.isolation not in ("none", "worktree") or s.max_iterations < 1 for s in options.subagents):
142
+ raise ConfigurationError("Subagent isolation must be none/worktree and iterations positive")
143
+ rules = [parse_rule(text) for text in options.deny_rules]
144
+ if any(rule is None or rule.behavior != "deny" for rule in rules):
145
+ raise ConfigurationError("deny_rules must contain valid 'deny ...' rules")
146
+ config = Config(
147
+ base_url=model.base_url, model=model.model, workspace=options.workspace,
148
+ summary_model=model.model, explore_model=model.model,
149
+ request_timeout=model.request_timeout, mode="default", auto_approve=False,
150
+ enable_explore=False, enable_plan=False, enable_skills=bool(options.skill_directories),
151
+ skill_directories=tuple(Path(p).resolve() for p in options.skill_directories),
152
+ enable_web_search=False, enable_browser=False, enable_plugins=False,
153
+ enable_mcp=False, enable_hooks=False, enable_agents=False,
154
+ enable_chenshu=False, enable_peers=False, turn_extension=0,
155
+ load_project_instructions=options.load_project_instructions,
156
+ system_prompt=options.system_prompt, max_iterations=options.max_iterations,
157
+ budget_tokens=options.budget_tokens, extra_env=dict(options.tool_env),
158
+ )
159
+ client = model.client
160
+ if client is None:
161
+ if not model.api_key:
162
+ raise ConfigurationError("api_key must be explicitly supplied")
163
+ import httpx
164
+ from openai import OpenAI
165
+ from xiaoyu.responses import wrap
166
+
167
+ base = OpenAI(api_key=model.api_key, base_url=model.base_url,
168
+ timeout=model.request_timeout, max_retries=0,
169
+ http_client=httpx.Client(trust_env=False))
170
+ self._owned_clients.append(base)
171
+
172
+ def anthropic_factory():
173
+ from anthropic import Anthropic
174
+ other = Anthropic(api_key=model.api_key, base_url=model.base_url,
175
+ timeout=model.request_timeout, max_retries=0,
176
+ http_client=httpx.Client(trust_env=False))
177
+ self._owned_clients.append(other)
178
+ return other
179
+
180
+ client = wrap(base, ("*",) if model.protocol == "responses" else (),
181
+ ("*",) if model.protocol == "anthropic" else (),
182
+ anthropic_factory=anthropic_factory, provider="sdk")
183
+ registry = Registry([Provider("sdk", model.base_url, model.api_key)], clients={"sdk": client},
184
+ inherit_environment=False)
185
+ if options.mcp_servers:
186
+ from xiaoyu.mcp import McpManager, ServerSpec
187
+ if len({s.name for s in options.mcp_servers}) != len(options.mcp_servers):
188
+ raise ConfigurationError("MCP server names must be unique")
189
+ self._mcp_directory = tempfile.TemporaryDirectory(prefix="xiaoyu-sdk-mcp-")
190
+ self._mcp = McpManager([
191
+ ServerSpec(name=s.name, command=s.command, args=list(s.args), env=s.env,
192
+ url=s.url, headers=s.headers, timeout=s.timeout)
193
+ for s in options.mcp_servers
194
+ ], state_dir=Path(self._mcp_directory.name))
195
+ self._mcp.start()
196
+ self._toolbox = Toolbox(config, only=list(options.builtin_tools) if options.builtin_tools is not None else None,
197
+ mcp_view=self._mcp)
198
+ if options.plugins:
199
+ from xiaoyu.tools import load_plugin_tools
200
+ selected = tuple((p.distribution, p.name) for p in options.plugins)
201
+ if len(set(selected)) != len(selected):
202
+ raise ConfigurationError("Duplicate plugin selection")
203
+ try:
204
+ plugin_tools = load_plugin_tools(config, selected=selected)
205
+ except Exception as exc:
206
+ raise ConfigurationError("Selected plugin could not be loaded") from exc
207
+ for plugin_tool in plugin_tools:
208
+ if plugin_tool.name == "structured_output" or self._toolbox.get(plugin_tool.name) is not None:
209
+ raise ConfigurationError(f"Duplicate/reserved plugin tool: {plugin_tool.name}")
210
+ self._toolbox.register(plugin_tool)
211
+ for tool in options.tools:
212
+ if tool.name == "structured_output" or self._toolbox.get(tool.name) is not None:
213
+ raise ConfigurationError(f"Duplicate/reserved tool name: {tool.name}")
214
+ validator = validator_for(tool.parameters)
215
+ if tool.parameters.get("type") != "object":
216
+ raise ConfigurationError("Tool parameter schemas must have type object")
217
+
218
+ def handler(_tool=tool, _validator=validator, **args):
219
+ if not _validator.is_valid(args):
220
+ return "ERROR: Tool arguments do not satisfy the declared schema"
221
+ try:
222
+ result = self._callback(_tool.handler, **args)
223
+ error = isinstance(result, ToolResult) and result.is_error
224
+ content = result.content if isinstance(result, ToolResult) else result
225
+ text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False, allow_nan=False)
226
+ return ("ERROR: " if error else "") + text
227
+ except Interrupted:
228
+ raise
229
+ except Exception:
230
+ return "ERROR: Host tool failed"
231
+
232
+ self._toolbox.register(KernelTool(tool.name, tool.description, tool.parameters,
233
+ handler, tool.requires_approval, coerce_arguments=False))
234
+ if resume_from is not None:
235
+ path = Path(resume_from).resolve()
236
+ if not path.is_file():
237
+ raise SessionStorageError("Session log does not exist")
238
+ self._log = SessionLog(path)
239
+ with path.open(encoding="utf-8") as stream:
240
+ metadata = json.loads(stream.readline())
241
+ if Path(metadata.get("workspace", "")).resolve() != options.workspace:
242
+ raise SessionStorageError("Resume workspace differs; use fork to change workspace")
243
+ elif options.session_dir is not None:
244
+ self._log = SessionLog.create(model.model, str(options.workspace),
245
+ directory=Path(options.session_dir), session_id=uuid.uuid4().hex)
246
+ if self._log is not None:
247
+ # CLI may degrade to unlocked/broken logs. SDK persistence is fail-closed.
248
+ if not self._log.locked or self._log.broken_reason:
249
+ raise SessionStorageError("Cannot lock or write session log")
250
+ self._agent = Agent(config, self._toolbox, registry=registry, approver=self._approve,
251
+ sink=_Sink(self), permissions=Permissions(options.workspace, [r for r in rules if r is not None]),
252
+ hook_engine=_Hooks(self, options.hooks), session_log=self._log,
253
+ upstream_stop=self._cancel.is_set)
254
+ if resume_from is not None:
255
+ assert self._log is not None
256
+ messages = load_messages(self._log.path)
257
+ if messages.corrupt_lines:
258
+ raise SessionStorageError("Session contains corrupt records")
259
+ self._agent.restore(messages, source=str(self._log.path), copy=False)
260
+ if options.subagents:
261
+ from xiaoyu.agents import AgentSpec, ParentGuards, make_subagent_tool
262
+ agent = self._agent
263
+ for spec in options.subagents:
264
+ child_tool = make_subagent_tool(
265
+ AgentSpec(spec.name, spec.description, spec.system_prompt, spec.tools,
266
+ model=spec.model, max_iterations=spec.max_iterations,
267
+ isolation=spec.isolation, require_isolation=spec.isolation == "worktree"),
268
+ config, registry, self._agent.usage, self._agent.sink,
269
+ self._approve, self._agent.permissions,
270
+ stop_requested=self._agent.interrupt_requested,
271
+ guards=ParentGuards(mode=lambda: "default", hooks=lambda: agent.hook_engine),
272
+ )
273
+ if self._toolbox.get(child_tool.name) is not None:
274
+ raise ConfigurationError("Duplicate subagent tool name")
275
+ self._toolbox.register(child_tool)
276
+
277
+ def _callback(self, fn, *args, timeout: float | None = None, **kwargs):
278
+ if self._cancel.is_set():
279
+ raise Interrupted()
280
+ if inspect.iscoroutinefunction(fn):
281
+ if self._loop is None:
282
+ raise ConfigurationError("Async callbacks require AsyncSession")
283
+ future: Future[Any] = Future()
284
+ settled = threading.Event()
285
+ with self._mutex:
286
+ self._async_settling.add(settled)
287
+
288
+ def launch():
289
+ if future.cancelled():
290
+ settled.set()
291
+ return
292
+ task = self._loop.create_task(fn(*args, **kwargs))
293
+
294
+ def finish(task):
295
+ try:
296
+ if task.cancelled():
297
+ future.cancel()
298
+ elif not future.done():
299
+ error = task.exception()
300
+ if error is not None:
301
+ future.set_exception(error)
302
+ else:
303
+ future.set_result(task.result())
304
+ else:
305
+ task.exception() # Retrieve failures after host cancellation.
306
+ except InvalidStateError:
307
+ pass # Cancellation raced with delivering the callback result.
308
+ finally:
309
+ settled.set()
310
+
311
+ task.add_done_callback(finish)
312
+ future.add_done_callback(lambda f: self._loop.call_soon_threadsafe(task.cancel) if f.cancelled() else None)
313
+
314
+ self._loop.call_soon_threadsafe(launch)
315
+ else:
316
+ future = self._callback_pool.submit(fn, *args, **kwargs)
317
+ with self._mutex:
318
+ self._callbacks.add(future)
319
+ deadline = time.monotonic() + timeout if timeout is not None else None
320
+ try:
321
+ while True:
322
+ if self._cancel.is_set():
323
+ future.cancel()
324
+ raise Interrupted()
325
+ try:
326
+ result = future.result(timeout=0.05)
327
+ if inspect.isawaitable(result):
328
+ if inspect.iscoroutine(result):
329
+ result.close()
330
+ raise ConfigurationError("Use an async def callback for async work")
331
+ return result
332
+ except FutureTimeout:
333
+ if future.done():
334
+ raise # The callback itself raised TimeoutError.
335
+ if deadline is not None and time.monotonic() >= deadline:
336
+ future.cancel()
337
+ raise TimeoutError("Host callback timed out") from None
338
+ finally:
339
+ # Keep running synchronous callbacks owned until they actually finish.
340
+ future.add_done_callback(self._callback_done)
341
+
342
+ def _callback_done(self, future: Future) -> None:
343
+ with self._mutex:
344
+ self._callbacks.discard(future)
345
+
346
+ def _approve(self, name: str, args: dict):
347
+ if self.options.approver is None:
348
+ return Deny("Host approval is required")
349
+ try:
350
+ return self._callback(self.options.approver, name, copy.deepcopy(args),
351
+ timeout=self.options.approval_timeout)
352
+ except Interrupted:
353
+ raise
354
+ except Exception:
355
+ return Deny("Host approval failed or timed out")
356
+
357
+ @property
358
+ def closed(self) -> bool:
359
+ return self._closed
360
+
361
+ @property
362
+ def session_path(self) -> Path | None:
363
+ return self._log.path if self._log else None
364
+
365
+ def _check_idle(self) -> None:
366
+ if self._closed or self._closing:
367
+ raise SessionClosedError("Session is closed or closing")
368
+ if self._stream_active or self._future is not None and not self._future.done():
369
+ raise SessionBusyError("A turn is already running")
370
+ self._async_settling = {e for e in self._async_settling if not e.is_set()}
371
+ self._callbacks = {f for f in self._callbacks if not f.done()}
372
+ if self._callbacks or self._async_settling:
373
+ raise SessionBusyError("A host callback is still settling")
374
+
375
+ def _start(self, prompt: str, output: OutputSpec | None, stream: bool = False) -> Future:
376
+ # Invalid schema never mutates history or starts execution.
377
+ contract = OutputContract(output.schema, output.max_retries) if output else None
378
+ if self._loop is None and any(inspect.iscoroutinefunction(fn) for fn in (
379
+ self.options.approver, *(t.handler for t in self.options.tools),
380
+ *(h.callback for h in self.options.hooks),
381
+ )):
382
+ raise ConfigurationError("Async callbacks require AsyncSession")
383
+ with self._mutex:
384
+ self._check_idle()
385
+ self._cancel.clear()
386
+ self._events = queue.Queue(self.options.event_buffer_size) if stream else None
387
+ self._stream_active = stream
388
+ self._future = self._executor.submit(self._execute, prompt, contract)
389
+ return self._future
390
+
391
+ def _execute(self, prompt: str, contract: OutputContract | None) -> RunResult:
392
+ assert self._agent is not None
393
+ try:
394
+ result = measured_send(self._agent, prompt, output_contract=contract)
395
+ if result.interrupted:
396
+ self._wait_callbacks(only_async=True)
397
+ if self._log and self._log.broken_reason:
398
+ raise SessionStorageError("Session log write failed")
399
+ return result
400
+ except (SessionStorageError, CloseTimeoutError):
401
+ raise
402
+ except Exception as exc:
403
+ self._agent.close_open_tool_calls("Execution failed; outcome may be incomplete.")
404
+ raise ExecutionError(f"Execution failed ({type(exc).__name__})") from exc
405
+
406
+ def run(self, prompt: str, *, output: OutputSpec | None = None) -> RunResult:
407
+ return self._start(prompt, output).result()
408
+
409
+ def _next_event(self, future: Future):
410
+ assert self._events is not None
411
+ try:
412
+ return self._events.get(timeout=0.05)
413
+ except queue.Empty:
414
+ return RunCompleted(future.result()) if future.done() else None
415
+
416
+ def stream(self, prompt: str, *, output: OutputSpec | None = None) -> Generator[UIEvent, None, None]:
417
+ future = self._start(prompt, output, stream=True)
418
+ try:
419
+ while True:
420
+ event = self._next_event(future)
421
+ if event is not None:
422
+ yield event
423
+ if isinstance(event, RunCompleted):
424
+ break
425
+ finally:
426
+ try:
427
+ if not future.done():
428
+ self.interrupt()
429
+ self._wait_turn(future)
430
+ finally:
431
+ self._stream_active = False
432
+
433
+ def interrupt(self) -> None:
434
+ self._cancel.set()
435
+ if self._agent is not None:
436
+ self._agent.interrupt()
437
+
438
+ def _wait_turn(self, future: Future) -> None:
439
+ try:
440
+ future.result(timeout=self.options.close_timeout)
441
+ except FutureTimeout:
442
+ raise CloseTimeoutError("Execution has not stopped yet") from None
443
+ except Exception:
444
+ # Execution failure is reported by run/stream; cleanup still proceeds.
445
+ pass
446
+ self._wait_callbacks()
447
+
448
+ def _wait_callbacks(self, *, only_async: bool = False) -> None:
449
+ deadline = time.monotonic() + self.options.close_timeout
450
+ while True:
451
+ with self._mutex:
452
+ pending = (not only_async and any(not f.done() for f in self._callbacks)) or any(
453
+ not e.is_set() for e in self._async_settling
454
+ )
455
+ if not pending:
456
+ return
457
+ if time.monotonic() >= deadline:
458
+ raise CloseTimeoutError("A host callback has not stopped yet")
459
+ time.sleep(0.01)
460
+
461
+ def fork(self, *, options: SessionOptions | None = None) -> Session:
462
+ with self._mutex:
463
+ self._check_idle()
464
+ assert self._agent is not None
465
+ messages = copy.deepcopy(self._agent.messages[1:])
466
+ child = Session(options or self.options)
467
+ try:
468
+ assert child._agent is not None
469
+ child._agent.restore(messages, source=str(self.session_path or "memory"))
470
+ except BaseException:
471
+ child.close()
472
+ raise
473
+ return child
474
+
475
+ def checkpoints(self) -> tuple[int, ...]:
476
+ with self._mutex:
477
+ self._check_idle()
478
+ assert self._toolbox is not None
479
+ return tuple(p.index for p in self._toolbox.rewind.points())
480
+
481
+ def rewind(self, index: int, *, conversation: bool = True, files: bool = True) -> RewindResult:
482
+ with self._mutex:
483
+ self._check_idle()
484
+ assert self._toolbox is not None and self._agent is not None
485
+ return self._agent.rewind_result(index, conversation=conversation, files=files)
486
+
487
+ def mcp_status(self) -> tuple[McpServerStatus, ...]:
488
+ """Read immutable state snapshots; never include server error bodies/headers."""
489
+ if self._mcp is None:
490
+ return ()
491
+ return tuple(McpServerStatus(name, state) for name, state in self._mcp.server_states().items())
492
+
493
+ def close(self) -> None:
494
+ with self._close_mutex:
495
+ self._close()
496
+
497
+ def _close(self) -> None:
498
+ with self._mutex:
499
+ if self._closed:
500
+ return
501
+ self._closing = True
502
+ self.interrupt()
503
+ future = self._future
504
+ if future is not None:
505
+ self._wait_turn(future)
506
+ self._wait_callbacks()
507
+ if self._cleanup_future is None:
508
+ self._cleanup_future = self._executor.submit(self._release_resources)
509
+ try:
510
+ pending = self._cleanup_future.result(timeout=self.options.close_timeout)
511
+ except FutureTimeout:
512
+ if self._cleanup_future.done():
513
+ self._cleanup_future = None
514
+ raise CloseTimeoutError("Resource cleanup is still pending; retry close") from None
515
+ except Exception as exc:
516
+ self._cleanup_future = None
517
+ raise SessionStorageError("Resource cleanup failed; retry close") from exc
518
+ if pending:
519
+ self._cleanup_future = None
520
+ raise CloseTimeoutError("Resources still stopping: " + ", ".join(pending))
521
+ self._executor.shutdown(wait=True)
522
+ self._callback_pool.shutdown(wait=True)
523
+ self._closed = True
524
+
525
+ def _release_resources(self) -> tuple[str, ...]:
526
+ pending: tuple[str, ...] = ()
527
+ if self._toolbox is not None:
528
+ self._toolbox.tasks.shutdown()
529
+ pending += self._toolbox.tasks.shutdown_pending()
530
+ if self._mcp is not None:
531
+ self._mcp.close()
532
+ pending += self._mcp.shutdown_pending()
533
+ if pending:
534
+ return pending
535
+ if self._mcp_directory is not None:
536
+ self._mcp_directory.cleanup()
537
+ while self._owned_clients:
538
+ self._owned_clients[-1].close()
539
+ self._owned_clients.pop()
540
+ if self._log is not None:
541
+ self._log.close()
542
+ return ()
543
+
544
+ def __enter__(self) -> Session:
545
+ return self
546
+
547
+ def __exit__(self, *exc) -> None:
548
+ self.close()
549
+
550
+
551
+ class AsyncSession:
552
+ """Async host interface; callbacks run on the caller's event loop."""
553
+
554
+ def __init__(self, options: SessionOptions, *, resume_from: Path | None = None) -> None:
555
+ self._session = Session(options, resume_from=resume_from)
556
+
557
+ def _bind_loop(self) -> None:
558
+ loop = asyncio.get_running_loop()
559
+ old = self._session._loop
560
+ if old is not None and old is not loop:
561
+ raise ConfigurationError("AsyncSession belongs to a different event loop")
562
+ self._session._loop = loop
563
+
564
+ @property
565
+ def closed(self) -> bool:
566
+ return self._session.closed
567
+
568
+ @property
569
+ def session_path(self) -> Path | None:
570
+ return self._session.session_path
571
+
572
+ async def run(self, prompt: str, *, output: OutputSpec | None = None) -> RunResult:
573
+ self._bind_loop()
574
+ future = self._session._start(prompt, output)
575
+ wrapped = asyncio.wrap_future(future)
576
+ wrapped.add_done_callback(lambda f: f.exception() if not f.cancelled() else None)
577
+ try:
578
+ return await asyncio.shield(wrapped)
579
+ except asyncio.CancelledError:
580
+ self.interrupt()
581
+ await asyncio.to_thread(self._session._wait_turn, future)
582
+ raise
583
+
584
+ async def stream(self, prompt: str, *, output: OutputSpec | None = None) -> AsyncGenerator[UIEvent, None]:
585
+ self._bind_loop()
586
+ future = self._session._start(prompt, output, stream=True)
587
+ try:
588
+ while True:
589
+ event = await asyncio.to_thread(self._session._next_event, future)
590
+ if event is not None:
591
+ yield event
592
+ if isinstance(event, RunCompleted):
593
+ break
594
+ finally:
595
+ try:
596
+ if not future.done():
597
+ self.interrupt()
598
+ await asyncio.to_thread(self._session._wait_turn, future)
599
+ finally:
600
+ self._session._stream_active = False
601
+
602
+ def interrupt(self) -> None:
603
+ self._session.interrupt()
604
+
605
+ async def close(self) -> None:
606
+ await asyncio.shield(asyncio.to_thread(self._session.close))
607
+
608
+ async def fork(self, *, options: SessionOptions | None = None) -> AsyncSession:
609
+ self._bind_loop()
610
+ child = object.__new__(AsyncSession)
611
+ child._session = await asyncio.to_thread(self._session.fork, options=options)
612
+ return child
613
+
614
+ async def rewind(self, index: int, *, conversation: bool = True, files: bool = True) -> RewindResult:
615
+ return await asyncio.to_thread(self._session.rewind, index, conversation=conversation, files=files)
616
+
617
+ def checkpoints(self) -> tuple[int, ...]:
618
+ return self._session.checkpoints()
619
+
620
+ def mcp_status(self) -> tuple[McpServerStatus, ...]:
621
+ return self._session.mcp_status()
622
+
623
+ async def __aenter__(self) -> AsyncSession:
624
+ self._bind_loop()
625
+ return self
626
+
627
+ async def __aexit__(self, *exc) -> None:
628
+ await self.close()
629
+
630
+
631
+ def run(prompt: str, options: SessionOptions, *, output: OutputSpec | None = None) -> RunResult:
632
+ with Session(options) as session:
633
+ return session.run(prompt, output=output)
634
+
635
+
636
+ async def run_async(prompt: str, options: SessionOptions, *, output: OutputSpec | None = None) -> RunResult:
637
+ async with AsyncSession(options) as session:
638
+ return await session.run(prompt, output=output)
639
+
640
+
641
+ def list_sessions(directory: Path, *, limit: int = 20, workspace: Path | None = None) -> list[SessionInfo]:
642
+ """List only the explicitly selected host session directory."""
643
+ return _list_sessions(limit, str(workspace.resolve()) if workspace else None, directory=Path(directory))
@@ -0,0 +1,142 @@
1
+ """Host-owned configuration. Sessions take defensive copies of mutable fields."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Awaitable, Callable, Literal
7
+
8
+ from xiaoyu.agent import Allow, Deny
9
+ from xiaoyu.hooks import Decision as HookDecision
10
+
11
+ Approval = bool | str | tuple[bool, str] | Allow | Deny
12
+ Approver = Callable[[str, dict[str, Any]], Approval | Awaitable[Approval]]
13
+ ToolHandler = Callable[..., Any]
14
+ HookHandler = Callable[[dict[str, Any]], HookDecision | Awaitable[HookDecision]]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ModelOptions:
19
+ model: str
20
+ base_url: str = "https://api.openai.com/v1"
21
+ api_key: str = field(default="", repr=False)
22
+ protocol: Literal["chat", "responses", "anthropic"] = "chat"
23
+ request_timeout: float = 120.0
24
+ # Borrowed OpenAI-compatible synchronous client; never closed by the SDK.
25
+ client: Any = field(default=None, repr=False, compare=False)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class OutputSpec:
30
+ schema: dict[str, Any]
31
+ max_retries: int = 2
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Tool:
36
+ name: str
37
+ description: str
38
+ parameters: dict[str, Any]
39
+ handler: ToolHandler = field(repr=False)
40
+ requires_approval: bool = True
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ToolResult:
45
+ content: Any
46
+ is_error: bool = False
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Hook:
51
+ event: Literal["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"]
52
+ callback: HookHandler = field(repr=False)
53
+ tool_name: str = ""
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class McpServer:
58
+ name: str
59
+ command: str = ""
60
+ args: tuple[str, ...] = ()
61
+ env: dict[str, str] = field(default_factory=dict, repr=False)
62
+ url: str = ""
63
+ headers: dict[str, str] = field(default_factory=dict, repr=False)
64
+ timeout: float = 60.0
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Subagent:
69
+ name: str
70
+ description: str
71
+ system_prompt: str
72
+ tools: tuple[str, ...]
73
+ model: str = ""
74
+ max_iterations: int = 12
75
+ isolation: Literal["none", "worktree"] = "none"
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class McpServerStatus:
80
+ name: str
81
+ state: str
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Plugin:
86
+ """An explicitly selected installed xiaoyu.tools entry point."""
87
+
88
+ name: str
89
+ distribution: str
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class SessionOptions:
94
+ model: ModelOptions
95
+ workspace: Path
96
+ system_prompt: str | None = None
97
+ builtin_tools: tuple[str, ...] | None = None
98
+ tools: tuple[Tool, ...] = ()
99
+ hooks: tuple[Hook, ...] = ()
100
+ mcp_servers: tuple[McpServer, ...] = ()
101
+ subagents: tuple[Subagent, ...] = ()
102
+ approver: Approver | None = field(default=None, repr=False)
103
+ approval_timeout: float = 120.0
104
+ close_timeout: float = 10.0
105
+ max_iterations: int = 50
106
+ budget_tokens: int | None = None
107
+ load_project_instructions: bool = False
108
+ skill_directories: tuple[Path, ...] = ()
109
+ session_dir: Path | None = None
110
+ # Environment additions apply to child tools, never to os.environ.
111
+ tool_env: dict[str, str] = field(default_factory=dict, repr=False)
112
+ deny_rules: tuple[str, ...] = ()
113
+ event_buffer_size: int = 128
114
+ plugins: tuple[Plugin, ...] = ()
115
+
116
+
117
+ class SDKError(Exception):
118
+ """Base class for host-facing SDK errors."""
119
+
120
+
121
+ class ConfigurationError(SDKError, ValueError):
122
+ pass
123
+
124
+
125
+ class SessionClosedError(SDKError):
126
+ pass
127
+
128
+
129
+ class SessionBusyError(SDKError):
130
+ pass
131
+
132
+
133
+ class CloseTimeoutError(SDKError, TimeoutError):
134
+ """Work is still settling; call close again before reusing its resources."""
135
+
136
+
137
+ class ExecutionError(SDKError):
138
+ """Model/engine failure. Original exception is available as __cause__."""
139
+
140
+
141
+ class SessionStorageError(SDKError):
142
+ pass
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: xiaoyu-agent-sdk
3
+ Version: 0.58.0
4
+ Summary: In-process Python SDK for the Xiaoyu workspace execution engine
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/pholex/zhinu
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: xiaoyu-agent[sdk]==0.58.0
11
+ Dynamic: license-file
12
+ Dynamic: requires-dist
13
+
14
+ # Xiaoyu Agent SDK
15
+
16
+ Python 3.11+ host API for the Xiaoyu workspace execution engine. The SDK runs in
17
+ your process and depends on the exact same version of `xiaoyu-agent`.
18
+
19
+ ```python
20
+ from pathlib import Path
21
+ from xiaoyu_agent_sdk import ModelOptions, SessionOptions, run
22
+
23
+ result = run("Explain this project", SessionOptions(
24
+ model=ModelOptions(model="your-model", api_key="your-key"),
25
+ workspace=Path.cwd(),
26
+ ))
27
+ print(result.text)
28
+ ```
29
+
30
+ Model credentials are explicit. Mutating tools require host approval by default.
31
+ Use `Session` for multiple turns, `AsyncSession` for async applications, and
32
+ `OutputSpec` for validated JSON results with bounded repair attempts.
33
+
34
+ See [SDK guide](https://github.com/pholex/zhinu/blob/main/docs/sdk.md) for ownership,
35
+ approval, streaming, structured output, extension and persistence contracts.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ src/xiaoyu_agent_sdk/__init__.py
6
+ src/xiaoyu_agent_sdk/_version.py
7
+ src/xiaoyu_agent_sdk/py.typed
8
+ src/xiaoyu_agent_sdk/session.py
9
+ src/xiaoyu_agent_sdk/types.py
10
+ src/xiaoyu_agent_sdk.egg-info/PKG-INFO
11
+ src/xiaoyu_agent_sdk.egg-info/SOURCES.txt
12
+ src/xiaoyu_agent_sdk.egg-info/dependency_links.txt
13
+ src/xiaoyu_agent_sdk.egg-info/requires.txt
14
+ src/xiaoyu_agent_sdk.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ xiaoyu-agent[sdk]==0.58.0
@@ -0,0 +1 @@
1
+ xiaoyu_agent_sdk