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.
- xiaoyu_agent_sdk-0.58.0/LICENSE +21 -0
- xiaoyu_agent_sdk-0.58.0/PKG-INFO +35 -0
- xiaoyu_agent_sdk-0.58.0/README.md +22 -0
- xiaoyu_agent_sdk-0.58.0/pyproject.toml +21 -0
- xiaoyu_agent_sdk-0.58.0/setup.cfg +4 -0
- xiaoyu_agent_sdk-0.58.0/setup.py +15 -0
- xiaoyu_agent_sdk-0.58.0/src/xiaoyu_agent_sdk/__init__.py +38 -0
- xiaoyu_agent_sdk-0.58.0/src/xiaoyu_agent_sdk/_version.py +1 -0
- xiaoyu_agent_sdk-0.58.0/src/xiaoyu_agent_sdk/py.typed +0 -0
- xiaoyu_agent_sdk-0.58.0/src/xiaoyu_agent_sdk/session.py +643 -0
- xiaoyu_agent_sdk-0.58.0/src/xiaoyu_agent_sdk/types.py +142 -0
- xiaoyu_agent_sdk-0.58.0/src/xiaoyu_agent_sdk.egg-info/PKG-INFO +35 -0
- xiaoyu_agent_sdk-0.58.0/src/xiaoyu_agent_sdk.egg-info/SOURCES.txt +14 -0
- xiaoyu_agent_sdk-0.58.0/src/xiaoyu_agent_sdk.egg-info/dependency_links.txt +1 -0
- xiaoyu_agent_sdk-0.58.0/src/xiaoyu_agent_sdk.egg-info/requires.txt +1 -0
- xiaoyu_agent_sdk-0.58.0/src/xiaoyu_agent_sdk.egg-info/top_level.txt +1 -0
|
@@ -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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xiaoyu-agent[sdk]==0.58.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xiaoyu_agent_sdk
|