toolplane-python-client 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.
- toolplane/__init__.py +106 -0
- toolplane/common/__init__.py +93 -0
- toolplane/common/base_config.py +129 -0
- toolplane/common/base_connection_manager.py +171 -0
- toolplane/common/base_session_manager.py +321 -0
- toolplane/common/base_tool_manager.py +347 -0
- toolplane/common/constants.py +47 -0
- toolplane/common/utils.py +310 -0
- toolplane/core/__init__.py +67 -0
- toolplane/core/config.py +107 -0
- toolplane/core/connection.py +285 -0
- toolplane/core/errors.py +298 -0
- toolplane/core/machine.py +480 -0
- toolplane/core/request.py +775 -0
- toolplane/core/session.py +332 -0
- toolplane/core/session_context.py +514 -0
- toolplane/core/task.py +130 -0
- toolplane/core/tool.py +329 -0
- toolplane/http_core/__init__.py +37 -0
- toolplane/http_core/http_config.py +97 -0
- toolplane/http_core/http_connection.py +409 -0
- toolplane/http_core/http_machine.py +298 -0
- toolplane/http_core/http_request.py +748 -0
- toolplane/http_core/http_session.py +348 -0
- toolplane/http_core/http_session_context.py +491 -0
- toolplane/http_core/http_task.py +101 -0
- toolplane/http_core/http_tool.py +400 -0
- toolplane/interfaces/__init__.py +27 -0
- toolplane/interfaces/client_interface.py +122 -0
- toolplane/interfaces/connection_interface.py +193 -0
- toolplane/interfaces/event_interface.py +290 -0
- toolplane/interfaces/request_interface.py +439 -0
- toolplane/interfaces/session_interface.py +288 -0
- toolplane/interfaces/tool_interface.py +441 -0
- toolplane/proto/__init__.py +0 -0
- toolplane/proto/service_pb2.py +315 -0
- toolplane/proto/service_pb2_grpc.py +2240 -0
- toolplane/provider_cli.py +268 -0
- toolplane/provider_registry.py +77 -0
- toolplane/provider_runtime.py +302 -0
- toolplane/toolkits/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/create_directory.py +94 -0
- toolplane/toolkits/standalone_tools/create_file.py +124 -0
- toolplane/toolkits/standalone_tools/file_search.py +229 -0
- toolplane/toolkits/standalone_tools/grep_search.py +372 -0
- toolplane/toolkits/standalone_tools/launcher.py +146 -0
- toolplane/toolkits/standalone_tools/list_dir.py +395 -0
- toolplane/toolkits/standalone_tools/read_file.py +346 -0
- toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
- toolplane/toolkits/standalone_tools/run_tests.py +66 -0
- toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
- toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
- toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
- toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
- toolplane/toolkits/swe/__init__.py +35 -0
- toolplane/toolkits/swe/create_directory.py +15 -0
- toolplane/toolkits/swe/create_file.py +15 -0
- toolplane/toolkits/swe/descriptions.py +273 -0
- toolplane/toolkits/swe/execute_bash.py +93 -0
- toolplane/toolkits/swe/file_editor.py +775 -0
- toolplane/toolkits/swe/file_search.py +16 -0
- toolplane/toolkits/swe/finish.py +50 -0
- toolplane/toolkits/swe/grep_search.py +19 -0
- toolplane/toolkits/swe/list_dir.py +407 -0
- toolplane/toolkits/swe/read_file.py +18 -0
- toolplane/toolkits/swe/replace_string_in_file.py +17 -0
- toolplane/toolkits/swe/search.py +260 -0
- toolplane/toolkits/swe/semantic_search.py +20 -0
- toolplane/toolkits/swe/str_replace_editor.py +647 -0
- toolplane/toolkits/swe/submit.py +29 -0
- toolplane/toolkits/swe/swe_toolkit.py +1296 -0
- toolplane/toolplane_client.py +686 -0
- toolplane/toolplane_http_client.py +681 -0
- toolplane/utils/__init__.py +3 -0
- toolplane/utils/schema.py +146 -0
- toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
- toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
- toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
- toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
- toolplane_python_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""toolplane-provider: serve, validate, test, and inspect tool files.
|
|
2
|
+
|
|
3
|
+
The consumer of a tools file — a Python module using the bare ``@tool``
|
|
4
|
+
decorator. ``serve`` binds the collected tools to a session and runs the
|
|
5
|
+
provider loop (claim, execute, heartbeat, lease renewal); ``validate``
|
|
6
|
+
checks the file without executing it; ``call`` runs one tool locally;
|
|
7
|
+
``schema`` prints a tool's generated JSON schema.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import ast
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from typing import List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from toolplane import Toolplane
|
|
18
|
+
from toolplane.core.errors import ToolplaneError
|
|
19
|
+
from toolplane.provider_registry import RegistryTool, clear, collect
|
|
20
|
+
from toolplane.utils.schema import generate_schema_from_function
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("toolplane.provider_cli")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_module(path: str):
|
|
26
|
+
"""Import a tools file as a module (executes it — the standard Python
|
|
27
|
+
import contract; use ``validate`` for an execution-free check)."""
|
|
28
|
+
import importlib.abc
|
|
29
|
+
|
|
30
|
+
spec = importlib.util.spec_from_file_location("_toolplane_tools", path)
|
|
31
|
+
if spec is None or spec.loader is None:
|
|
32
|
+
raise ValueError(f"cannot import {path}")
|
|
33
|
+
module = importlib.util.module_from_spec(spec)
|
|
34
|
+
spec.loader.exec_module(module)
|
|
35
|
+
return module
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _registry_tools(path: str) -> List[RegistryTool]:
|
|
39
|
+
clear()
|
|
40
|
+
_load_module(path)
|
|
41
|
+
return collect()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _decorated_functions(source: str) -> List[Tuple[str, Optional[str]]]:
|
|
45
|
+
"""Extract (function name, schema literal) pairs for functions
|
|
46
|
+
decorated with @tool, without executing the module."""
|
|
47
|
+
parsed = ast.parse(source)
|
|
48
|
+
pairs: List[Tuple[str, Optional[str]]] = []
|
|
49
|
+
for node in ast.walk(parsed):
|
|
50
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
51
|
+
continue
|
|
52
|
+
for decorator in node.decorator_list:
|
|
53
|
+
name = None
|
|
54
|
+
if isinstance(decorator, ast.Name) and decorator.id == "tool":
|
|
55
|
+
name = node.name
|
|
56
|
+
elif isinstance(decorator, ast.Call):
|
|
57
|
+
func = decorator.func
|
|
58
|
+
if isinstance(func, ast.Name) and func.id == "tool":
|
|
59
|
+
name = node.name
|
|
60
|
+
if name:
|
|
61
|
+
pairs.append((node.name, _schema_kwarg_literal(decorator)))
|
|
62
|
+
break
|
|
63
|
+
return pairs
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _schema_kwarg_literal(decorator: ast.AST) -> Optional[str]:
|
|
67
|
+
if not isinstance(decorator, ast.Call):
|
|
68
|
+
return None
|
|
69
|
+
for keyword in decorator.keywords:
|
|
70
|
+
if keyword.arg == "schema" and isinstance(keyword.value, ast.Constant):
|
|
71
|
+
value = keyword.value.value
|
|
72
|
+
return value if isinstance(value, str) else None
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
77
|
+
"""Execution-free check of a tools file: parseable, every @tool
|
|
78
|
+
function collectable, schemas well-formed (JSON object root — the
|
|
79
|
+
server's registration bar)."""
|
|
80
|
+
path = args.file
|
|
81
|
+
try:
|
|
82
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
83
|
+
source = handle.read()
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
print(f"cannot read {path}: {exc}", file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
pairs = _decorated_functions(source)
|
|
90
|
+
except SyntaxError as exc:
|
|
91
|
+
print(f"{path} is not valid Python: {exc}", file=sys.stderr)
|
|
92
|
+
return 2
|
|
93
|
+
|
|
94
|
+
if not pairs:
|
|
95
|
+
print(f"{path}: no @tool functions found — nothing to serve", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
|
|
98
|
+
failures = 0
|
|
99
|
+
for name, schema_literal in pairs:
|
|
100
|
+
if schema_literal is None:
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
decoded = json.loads(schema_literal)
|
|
104
|
+
except json.JSONDecodeError as exc:
|
|
105
|
+
print(f" {name}: schema is not valid JSON: {exc}")
|
|
106
|
+
failures += 1
|
|
107
|
+
continue
|
|
108
|
+
if not isinstance(decoded, dict):
|
|
109
|
+
print(f" {name}: schema root must be a JSON object")
|
|
110
|
+
failures += 1
|
|
111
|
+
|
|
112
|
+
if failures:
|
|
113
|
+
print(f"{path}: {failures} invalid schema(s)", file=sys.stderr)
|
|
114
|
+
return 2
|
|
115
|
+
|
|
116
|
+
print(f"{path}: OK — {len(pairs)} tool(s): {', '.join(name for name, _ in pairs)}")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
121
|
+
tools = _registry_tools(args.file)
|
|
122
|
+
if not tools:
|
|
123
|
+
print(
|
|
124
|
+
f"{args.file}: no @tool functions found — nothing to serve", file=sys.stderr
|
|
125
|
+
)
|
|
126
|
+
return 2
|
|
127
|
+
|
|
128
|
+
client = Toolplane(
|
|
129
|
+
server_host=args.host,
|
|
130
|
+
server_port=args.port,
|
|
131
|
+
api_key=args.api_key or "",
|
|
132
|
+
)
|
|
133
|
+
client = Toolplane(
|
|
134
|
+
server_host=args.host,
|
|
135
|
+
server_port=args.port,
|
|
136
|
+
api_key=args.api_key or "",
|
|
137
|
+
)
|
|
138
|
+
runtime = client.provider_runtime()
|
|
139
|
+
runtime._poll_interval = args.poll_interval
|
|
140
|
+
try:
|
|
141
|
+
session = runtime.create_session(session_id=args.session or None)
|
|
142
|
+
except ToolplaneError as exc:
|
|
143
|
+
# The session already exists (a re-serve or another provider owns
|
|
144
|
+
# it): attach to it instead of failing.
|
|
145
|
+
if args.session and "already exists" in str(exc).lower():
|
|
146
|
+
session = runtime.attach_session(args.session)
|
|
147
|
+
else:
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
for entry in tools:
|
|
151
|
+
schema = entry.schema or generate_schema_from_function(entry.func)
|
|
152
|
+
runtime.tool(
|
|
153
|
+
session_id=session.session_id,
|
|
154
|
+
name=entry.name,
|
|
155
|
+
description=entry.description,
|
|
156
|
+
stream=entry.stream,
|
|
157
|
+
tags=entry.tags,
|
|
158
|
+
)
|
|
159
|
+
session.register_tool(
|
|
160
|
+
name=entry.name,
|
|
161
|
+
func=entry.func,
|
|
162
|
+
schema=schema,
|
|
163
|
+
description=entry.description,
|
|
164
|
+
stream=entry.stream,
|
|
165
|
+
tags=entry.tags or [],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
print(f"serving {len(tools)} tool(s) in session {session.session_id}")
|
|
169
|
+
try:
|
|
170
|
+
runtime.run_forever()
|
|
171
|
+
except KeyboardInterrupt:
|
|
172
|
+
pass
|
|
173
|
+
finally:
|
|
174
|
+
runtime.stop()
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_call(args: argparse.Namespace) -> int:
|
|
179
|
+
tools = _registry_tools(args.file)
|
|
180
|
+
entry = next((t for t in tools if t.name == args.tool), None)
|
|
181
|
+
if entry is None:
|
|
182
|
+
print(
|
|
183
|
+
f"tool {args.tool} not found in {args.file}; available: "
|
|
184
|
+
f"{', '.join(t.name for t in tools) or '(none)'}",
|
|
185
|
+
file=sys.stderr,
|
|
186
|
+
)
|
|
187
|
+
return 3
|
|
188
|
+
try:
|
|
189
|
+
input_data = json.loads(args.input) if args.input else {}
|
|
190
|
+
except json.JSONDecodeError as exc:
|
|
191
|
+
print(f"--input must be a JSON object: {exc}", file=sys.stderr)
|
|
192
|
+
return 2
|
|
193
|
+
result = entry.func(**input_data) if isinstance(input_data, dict) else entry.func()
|
|
194
|
+
print(json.dumps(result, default=str) if not isinstance(result, str) else result)
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_schema(args: argparse.Namespace) -> int:
|
|
199
|
+
tools = _registry_tools(args.file)
|
|
200
|
+
entry = next((t for t in tools if t.name == args.tool), None)
|
|
201
|
+
if entry is None:
|
|
202
|
+
print(f"tool {args.tool} not found in {args.file}", file=sys.stderr)
|
|
203
|
+
return 3
|
|
204
|
+
generated = entry.schema or generate_schema_from_function(entry.func)
|
|
205
|
+
# generate_schema_from_function wraps the schema with name/description;
|
|
206
|
+
# the operator wants the schema itself.
|
|
207
|
+
body = (
|
|
208
|
+
generated.get("schema", generated) if isinstance(generated, dict) else generated
|
|
209
|
+
)
|
|
210
|
+
print(json.dumps(body, indent=2))
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
215
|
+
parser = argparse.ArgumentParser(
|
|
216
|
+
prog="toolplane-provider",
|
|
217
|
+
description="Serve and manage Toolplane tool files (bare @tool modules).",
|
|
218
|
+
)
|
|
219
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
220
|
+
|
|
221
|
+
serve = sub.add_parser("serve", help="bind a tools file to a session and serve it")
|
|
222
|
+
serve.add_argument(
|
|
223
|
+
"file", nargs="?", default="tools.py", help="tools file (default: ./tools.py)"
|
|
224
|
+
)
|
|
225
|
+
serve.add_argument(
|
|
226
|
+
"--session",
|
|
227
|
+
default=None,
|
|
228
|
+
help="session id (created when missing; generated when omitted)",
|
|
229
|
+
)
|
|
230
|
+
serve.add_argument("--host", default="localhost")
|
|
231
|
+
serve.add_argument("--port", type=int, default=9001)
|
|
232
|
+
serve.add_argument("--api-key", default="")
|
|
233
|
+
serve.add_argument("--poll-interval", type=float, default=1.0)
|
|
234
|
+
serve.set_defaults(func=cmd_serve)
|
|
235
|
+
|
|
236
|
+
validate = sub.add_parser(
|
|
237
|
+
"validate", help="check a tools file without executing it"
|
|
238
|
+
)
|
|
239
|
+
validate.add_argument("file", help="tools file")
|
|
240
|
+
validate.set_defaults(func=cmd_validate)
|
|
241
|
+
|
|
242
|
+
call = sub.add_parser("call", help="run one tool locally (no server)")
|
|
243
|
+
call.add_argument("file", help="tools file")
|
|
244
|
+
call.add_argument("tool", help="tool name")
|
|
245
|
+
call.add_argument(
|
|
246
|
+
"--input", default="{}", help="tool input as a JSON object string"
|
|
247
|
+
)
|
|
248
|
+
call.set_defaults(func=cmd_call)
|
|
249
|
+
|
|
250
|
+
schema = sub.add_parser("schema", help="print a tool's generated JSON schema")
|
|
251
|
+
schema.add_argument("file", help="tools file")
|
|
252
|
+
schema.add_argument("tool", help="tool name")
|
|
253
|
+
schema.set_defaults(func=cmd_schema)
|
|
254
|
+
|
|
255
|
+
return parser
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
259
|
+
logging.basicConfig(
|
|
260
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
|
|
261
|
+
)
|
|
262
|
+
parser = build_parser()
|
|
263
|
+
args = parser.parse_args(argv)
|
|
264
|
+
return args.func(args)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
sys.exit(main())
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Module-level tool registry for file-based tool modules.
|
|
2
|
+
|
|
3
|
+
A tools file decorated with the bare ``@tool`` imports nothing from
|
|
4
|
+
Toolplane and binds no session at import time — metadata is collected
|
|
5
|
+
here and the ``toolplane-provider`` CLI binds it to a session at serve.
|
|
6
|
+
Importing such a module performs no I/O.
|
|
7
|
+
|
|
8
|
+
from toolplane.provider_registry import tool
|
|
9
|
+
|
|
10
|
+
@tool(name="add", description="Add two numbers")
|
|
11
|
+
def add(a: int, b: int) -> int:
|
|
12
|
+
return a + b
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Callable, List, Optional
|
|
18
|
+
|
|
19
|
+
_lock = threading.Lock()
|
|
20
|
+
_registry: List["RegistryTool"] = []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RegistryTool:
|
|
25
|
+
"""A collected tool definition, pre-binding."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
func: Callable
|
|
29
|
+
description: Optional[str] = None
|
|
30
|
+
stream: bool = False
|
|
31
|
+
tags: Optional[List[str]] = None
|
|
32
|
+
schema: Optional[dict] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def tool(
|
|
36
|
+
name: Optional[str] = None,
|
|
37
|
+
description: Optional[str] = None,
|
|
38
|
+
stream: bool = False,
|
|
39
|
+
tags: Optional[List[str]] = None,
|
|
40
|
+
schema: Optional[dict] = None,
|
|
41
|
+
):
|
|
42
|
+
"""Collect a tool definition into the module registry. Import-time
|
|
43
|
+
only: no session, no server, no I/O. Works bare (@tool) or with
|
|
44
|
+
arguments (@tool(name=..., description=...))."""
|
|
45
|
+
|
|
46
|
+
def _register(func: Callable) -> Callable:
|
|
47
|
+
with _lock:
|
|
48
|
+
_registry.append(
|
|
49
|
+
RegistryTool(
|
|
50
|
+
name=name or func.__name__,
|
|
51
|
+
func=func,
|
|
52
|
+
description=description,
|
|
53
|
+
stream=stream,
|
|
54
|
+
tags=list(tags) if tags else None,
|
|
55
|
+
schema=schema,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return func
|
|
59
|
+
|
|
60
|
+
# Bare usage: @tool directly above a function.
|
|
61
|
+
if callable(name):
|
|
62
|
+
func, name = name, None
|
|
63
|
+
return _register(func)
|
|
64
|
+
|
|
65
|
+
return _register
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def collect() -> List[RegistryTool]:
|
|
69
|
+
"""Snapshot the collected tools."""
|
|
70
|
+
with _lock:
|
|
71
|
+
return list(_registry)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clear() -> None:
|
|
75
|
+
"""Reset the registry (test isolation)."""
|
|
76
|
+
with _lock:
|
|
77
|
+
_registry.clear()
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Explicit provider runtime for machine-backed execution."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable, Iterable, List, Optional, Set
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from .core.errors import ConnectionError, ToolplaneError
|
|
13
|
+
except ImportError:
|
|
14
|
+
from core.errors import ConnectionError, ToolplaneError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class _PendingTool:
|
|
19
|
+
"""A tool registration deferred until the runtime starts."""
|
|
20
|
+
|
|
21
|
+
session_id: str
|
|
22
|
+
name: str
|
|
23
|
+
func: Callable
|
|
24
|
+
description: Optional[str] = None
|
|
25
|
+
stream: bool = False
|
|
26
|
+
tags: List[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProviderRuntime:
|
|
30
|
+
"""Owns the provider lifecycle for an Toolplane client instance."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
client: Any,
|
|
35
|
+
session_ids: Optional[Iterable[str]] = None,
|
|
36
|
+
poll_interval: Optional[float] = None,
|
|
37
|
+
heartbeat_interval: Optional[int] = None,
|
|
38
|
+
):
|
|
39
|
+
self.client = client
|
|
40
|
+
self._registration_lock = threading.Lock()
|
|
41
|
+
self._pending_registrations: List[_PendingTool] = []
|
|
42
|
+
self._session_ids: Set[str] = set(session_ids or [])
|
|
43
|
+
self._poll_interval = (
|
|
44
|
+
poll_interval
|
|
45
|
+
if poll_interval is not None
|
|
46
|
+
else getattr(client.config, "poll_interval", 1.0)
|
|
47
|
+
)
|
|
48
|
+
self._heartbeat_interval = (
|
|
49
|
+
heartbeat_interval
|
|
50
|
+
if heartbeat_interval is not None
|
|
51
|
+
else getattr(client.config, "heartbeat_interval", 60)
|
|
52
|
+
)
|
|
53
|
+
self._running = False
|
|
54
|
+
self._main_thread: Optional[threading.Thread] = None
|
|
55
|
+
self._lock = threading.RLock()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def running(self) -> bool:
|
|
59
|
+
return self._running
|
|
60
|
+
|
|
61
|
+
def add_sessions(self, session_ids: Optional[Iterable[str]]) -> None:
|
|
62
|
+
if not session_ids:
|
|
63
|
+
return
|
|
64
|
+
for session_id in session_ids:
|
|
65
|
+
if session_id:
|
|
66
|
+
self._session_ids.add(session_id)
|
|
67
|
+
|
|
68
|
+
def managed_session_ids(self) -> List[str]:
|
|
69
|
+
session_ids: List[str] = []
|
|
70
|
+
seen: Set[str] = set()
|
|
71
|
+
for session_id in list(self._session_ids) + list(
|
|
72
|
+
getattr(self.client, "session_ids", [])
|
|
73
|
+
):
|
|
74
|
+
if not session_id or session_id in seen:
|
|
75
|
+
continue
|
|
76
|
+
session_ids.append(session_id)
|
|
77
|
+
seen.add(session_id)
|
|
78
|
+
return session_ids
|
|
79
|
+
|
|
80
|
+
def attach_session(self, session_id: str, register_machine: bool = True):
|
|
81
|
+
if not session_id:
|
|
82
|
+
raise ToolplaneError(
|
|
83
|
+
"ProviderRuntime.attach_session requires a non-empty session_id"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if not self.client.connection_manager.connected:
|
|
87
|
+
if not self.client.connect():
|
|
88
|
+
raise ConnectionError("Failed to connect to server")
|
|
89
|
+
|
|
90
|
+
self._session_ids.add(session_id)
|
|
91
|
+
return self.client.ensure_session_context(
|
|
92
|
+
session_id,
|
|
93
|
+
create_if_missing=False,
|
|
94
|
+
register_machine=register_machine,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def create_session(
|
|
98
|
+
self,
|
|
99
|
+
session_id: Optional[str] = None,
|
|
100
|
+
user_id: Optional[str] = None,
|
|
101
|
+
name: Optional[str] = None,
|
|
102
|
+
description: Optional[str] = None,
|
|
103
|
+
namespace: Optional[str] = None,
|
|
104
|
+
register_machine: bool = True,
|
|
105
|
+
):
|
|
106
|
+
context = self.client.create_session(
|
|
107
|
+
session_id=session_id,
|
|
108
|
+
user_id=user_id,
|
|
109
|
+
name=name,
|
|
110
|
+
description=description,
|
|
111
|
+
namespace=namespace,
|
|
112
|
+
register_machine=False,
|
|
113
|
+
)
|
|
114
|
+
self._session_ids.add(context.session_id)
|
|
115
|
+
if register_machine and not getattr(context, "machine_id", None):
|
|
116
|
+
context = self.attach_session(context.session_id, register_machine=True)
|
|
117
|
+
return context
|
|
118
|
+
|
|
119
|
+
def register_tool(
|
|
120
|
+
self,
|
|
121
|
+
session_id: str,
|
|
122
|
+
name: str,
|
|
123
|
+
func: Callable,
|
|
124
|
+
schema: Optional[dict] = None,
|
|
125
|
+
description: Optional[str] = None,
|
|
126
|
+
stream: bool = False,
|
|
127
|
+
tags: Optional[List[str]] = None,
|
|
128
|
+
) -> Callable:
|
|
129
|
+
context = self.attach_session(session_id, register_machine=True)
|
|
130
|
+
context.register_tool(
|
|
131
|
+
name=name,
|
|
132
|
+
func=func,
|
|
133
|
+
schema=schema,
|
|
134
|
+
description=description,
|
|
135
|
+
stream=stream,
|
|
136
|
+
tags=tags,
|
|
137
|
+
)
|
|
138
|
+
return func
|
|
139
|
+
|
|
140
|
+
def tool(
|
|
141
|
+
self,
|
|
142
|
+
session_id: Optional[str] = None,
|
|
143
|
+
name: Optional[str] = None,
|
|
144
|
+
description: Optional[str] = None,
|
|
145
|
+
stream: bool = False,
|
|
146
|
+
tags: Optional[List[str]] = None,
|
|
147
|
+
):
|
|
148
|
+
"""Queue a tool for registration at runtime start.
|
|
149
|
+
|
|
150
|
+
Decorating defers the (network) registration until
|
|
151
|
+
start_in_background/run_forever/poll_once, so importing a module of
|
|
152
|
+
decorated tools performs no I/O. session_id may be omitted when the
|
|
153
|
+
runtime manages exactly one session: the tool binds to it at
|
|
154
|
+
start (file-based tool modules use this to stay session-free).
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def decorator(func: Callable) -> Callable:
|
|
158
|
+
with self._registration_lock:
|
|
159
|
+
self._pending_registrations.append(
|
|
160
|
+
_PendingTool(
|
|
161
|
+
session_id=session_id,
|
|
162
|
+
name=name or func.__name__,
|
|
163
|
+
func=func,
|
|
164
|
+
description=description,
|
|
165
|
+
stream=stream,
|
|
166
|
+
tags=tags or [],
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
return func
|
|
170
|
+
|
|
171
|
+
return decorator
|
|
172
|
+
|
|
173
|
+
def _resolve_default_session(self) -> str:
|
|
174
|
+
"""Resolve the session an unbound (session_id=None) deferred tool
|
|
175
|
+
registers into: the runtime's single managed session. Ambiguous or
|
|
176
|
+
empty setups fail loudly instead of guessing."""
|
|
177
|
+
managed = self.managed_session_ids()
|
|
178
|
+
if len(managed) == 1:
|
|
179
|
+
return managed[0]
|
|
180
|
+
raise ToolplaneError(
|
|
181
|
+
"tool has no session_id and the runtime does not manage exactly "
|
|
182
|
+
f"one session (managed: {managed or 'none'}); bind it explicitly "
|
|
183
|
+
"or manage a single session"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _apply_pending_registrations(self) -> None:
|
|
187
|
+
"""Attach sessions and register every deferred tool.
|
|
188
|
+
|
|
189
|
+
A failed registration puts the item back at the head of the queue so
|
|
190
|
+
a later start attempt retries it; tools are never silently lost.
|
|
191
|
+
"""
|
|
192
|
+
while True:
|
|
193
|
+
with self._registration_lock:
|
|
194
|
+
if not self._pending_registrations:
|
|
195
|
+
return
|
|
196
|
+
item = self._pending_registrations.pop(0)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
self.register_tool(
|
|
200
|
+
session_id=item.session_id or self._resolve_default_session(),
|
|
201
|
+
name=item.name,
|
|
202
|
+
func=item.func,
|
|
203
|
+
description=item.description,
|
|
204
|
+
stream=item.stream,
|
|
205
|
+
tags=item.tags,
|
|
206
|
+
)
|
|
207
|
+
except Exception:
|
|
208
|
+
with self._registration_lock:
|
|
209
|
+
self._pending_registrations.insert(0, item)
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
def poll_once(self) -> None:
|
|
213
|
+
self._apply_pending_registrations()
|
|
214
|
+
for session_id in self.managed_session_ids():
|
|
215
|
+
context = self.client.ensure_session_context(
|
|
216
|
+
session_id,
|
|
217
|
+
create_if_missing=False,
|
|
218
|
+
register_machine=False,
|
|
219
|
+
)
|
|
220
|
+
if getattr(context, "machine_id", None):
|
|
221
|
+
context.poll_requests()
|
|
222
|
+
|
|
223
|
+
def start_in_background(
|
|
224
|
+
self, session_ids: Optional[Iterable[str]] = None
|
|
225
|
+
) -> "ProviderRuntime":
|
|
226
|
+
# Queued @tool registrations may introduce the very sessions this
|
|
227
|
+
# call is about to check for; apply them before validating.
|
|
228
|
+
self._apply_pending_registrations()
|
|
229
|
+
with self._lock:
|
|
230
|
+
self.add_sessions(session_ids)
|
|
231
|
+
if self._running:
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
if not self.client.connection_manager.connected:
|
|
235
|
+
if not self.client.connect():
|
|
236
|
+
raise ConnectionError("Failed to connect to server")
|
|
237
|
+
else:
|
|
238
|
+
self.client._initialize_sessions(register_machine=False)
|
|
239
|
+
|
|
240
|
+
managed_session_ids = self.managed_session_ids()
|
|
241
|
+
if not managed_session_ids:
|
|
242
|
+
raise ToolplaneError(
|
|
243
|
+
"ProviderRuntime requires at least one attached or configured session"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
self._apply_pending_registrations()
|
|
247
|
+
|
|
248
|
+
for session_id in managed_session_ids:
|
|
249
|
+
self.attach_session(session_id, register_machine=True)
|
|
250
|
+
|
|
251
|
+
self.client.machine_manager.start_heartbeat(self._heartbeat_interval)
|
|
252
|
+
# Keep claimed executions alive: the renewal loop extends each
|
|
253
|
+
# in-flight lease so long-running tools are not reclaimed mid-run.
|
|
254
|
+
self.client.request_manager.start_lease_renewal()
|
|
255
|
+
self._running = True
|
|
256
|
+
if hasattr(self.client, "running"):
|
|
257
|
+
self.client.running = True
|
|
258
|
+
self._main_thread = threading.Thread(
|
|
259
|
+
target=self._main_loop,
|
|
260
|
+
name="toolplane-provider-runtime",
|
|
261
|
+
daemon=True,
|
|
262
|
+
)
|
|
263
|
+
self._main_thread.start()
|
|
264
|
+
return self
|
|
265
|
+
|
|
266
|
+
def run_forever(self, session_ids: Optional[Iterable[str]] = None) -> None:
|
|
267
|
+
self.start_in_background(session_ids=session_ids)
|
|
268
|
+
try:
|
|
269
|
+
while self._running:
|
|
270
|
+
time.sleep(1)
|
|
271
|
+
except KeyboardInterrupt:
|
|
272
|
+
self.stop()
|
|
273
|
+
|
|
274
|
+
def stop(self) -> None:
|
|
275
|
+
with self._lock:
|
|
276
|
+
self._running = False
|
|
277
|
+
if hasattr(self.client, "running"):
|
|
278
|
+
self.client.running = False
|
|
279
|
+
|
|
280
|
+
if self._main_thread:
|
|
281
|
+
self._main_thread.join(timeout=1)
|
|
282
|
+
self._main_thread = None
|
|
283
|
+
|
|
284
|
+
self.client.request_manager.stop_lease_renewal()
|
|
285
|
+
self.client.machine_manager.stop_heartbeat()
|
|
286
|
+
|
|
287
|
+
def close(self) -> None:
|
|
288
|
+
self.stop()
|
|
289
|
+
|
|
290
|
+
def _main_loop(self) -> None:
|
|
291
|
+
while self._running:
|
|
292
|
+
try:
|
|
293
|
+
self.poll_once()
|
|
294
|
+
except Exception as exc:
|
|
295
|
+
logger.warning("Error in provider runtime loop: %s", exc)
|
|
296
|
+
time.sleep(self._poll_interval)
|
|
297
|
+
|
|
298
|
+
def __enter__(self) -> "ProviderRuntime":
|
|
299
|
+
return self.start_in_background()
|
|
300
|
+
|
|
301
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
302
|
+
self.stop()
|
|
File without changes
|
|
File without changes
|