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.
Files changed (81) hide show
  1. toolplane/__init__.py +106 -0
  2. toolplane/common/__init__.py +93 -0
  3. toolplane/common/base_config.py +129 -0
  4. toolplane/common/base_connection_manager.py +171 -0
  5. toolplane/common/base_session_manager.py +321 -0
  6. toolplane/common/base_tool_manager.py +347 -0
  7. toolplane/common/constants.py +47 -0
  8. toolplane/common/utils.py +310 -0
  9. toolplane/core/__init__.py +67 -0
  10. toolplane/core/config.py +107 -0
  11. toolplane/core/connection.py +285 -0
  12. toolplane/core/errors.py +298 -0
  13. toolplane/core/machine.py +480 -0
  14. toolplane/core/request.py +775 -0
  15. toolplane/core/session.py +332 -0
  16. toolplane/core/session_context.py +514 -0
  17. toolplane/core/task.py +130 -0
  18. toolplane/core/tool.py +329 -0
  19. toolplane/http_core/__init__.py +37 -0
  20. toolplane/http_core/http_config.py +97 -0
  21. toolplane/http_core/http_connection.py +409 -0
  22. toolplane/http_core/http_machine.py +298 -0
  23. toolplane/http_core/http_request.py +748 -0
  24. toolplane/http_core/http_session.py +348 -0
  25. toolplane/http_core/http_session_context.py +491 -0
  26. toolplane/http_core/http_task.py +101 -0
  27. toolplane/http_core/http_tool.py +400 -0
  28. toolplane/interfaces/__init__.py +27 -0
  29. toolplane/interfaces/client_interface.py +122 -0
  30. toolplane/interfaces/connection_interface.py +193 -0
  31. toolplane/interfaces/event_interface.py +290 -0
  32. toolplane/interfaces/request_interface.py +439 -0
  33. toolplane/interfaces/session_interface.py +288 -0
  34. toolplane/interfaces/tool_interface.py +441 -0
  35. toolplane/proto/__init__.py +0 -0
  36. toolplane/proto/service_pb2.py +315 -0
  37. toolplane/proto/service_pb2_grpc.py +2240 -0
  38. toolplane/provider_cli.py +268 -0
  39. toolplane/provider_registry.py +77 -0
  40. toolplane/provider_runtime.py +302 -0
  41. toolplane/toolkits/__init__.py +0 -0
  42. toolplane/toolkits/standalone_tools/__init__.py +0 -0
  43. toolplane/toolkits/standalone_tools/create_directory.py +94 -0
  44. toolplane/toolkits/standalone_tools/create_file.py +124 -0
  45. toolplane/toolkits/standalone_tools/file_search.py +229 -0
  46. toolplane/toolkits/standalone_tools/grep_search.py +372 -0
  47. toolplane/toolkits/standalone_tools/launcher.py +146 -0
  48. toolplane/toolkits/standalone_tools/list_dir.py +395 -0
  49. toolplane/toolkits/standalone_tools/read_file.py +346 -0
  50. toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
  51. toolplane/toolkits/standalone_tools/run_tests.py +66 -0
  52. toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
  53. toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
  54. toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
  55. toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
  56. toolplane/toolkits/swe/__init__.py +35 -0
  57. toolplane/toolkits/swe/create_directory.py +15 -0
  58. toolplane/toolkits/swe/create_file.py +15 -0
  59. toolplane/toolkits/swe/descriptions.py +273 -0
  60. toolplane/toolkits/swe/execute_bash.py +93 -0
  61. toolplane/toolkits/swe/file_editor.py +775 -0
  62. toolplane/toolkits/swe/file_search.py +16 -0
  63. toolplane/toolkits/swe/finish.py +50 -0
  64. toolplane/toolkits/swe/grep_search.py +19 -0
  65. toolplane/toolkits/swe/list_dir.py +407 -0
  66. toolplane/toolkits/swe/read_file.py +18 -0
  67. toolplane/toolkits/swe/replace_string_in_file.py +17 -0
  68. toolplane/toolkits/swe/search.py +260 -0
  69. toolplane/toolkits/swe/semantic_search.py +20 -0
  70. toolplane/toolkits/swe/str_replace_editor.py +647 -0
  71. toolplane/toolkits/swe/submit.py +29 -0
  72. toolplane/toolkits/swe/swe_toolkit.py +1296 -0
  73. toolplane/toolplane_client.py +686 -0
  74. toolplane/toolplane_http_client.py +681 -0
  75. toolplane/utils/__init__.py +3 -0
  76. toolplane/utils/schema.py +146 -0
  77. toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
  78. toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
  79. toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
  80. toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
  81. 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