kapsl 0.1.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.
- kapsl-0.1.0/LICENSE +21 -0
- kapsl-0.1.0/PKG-INFO +55 -0
- kapsl-0.1.0/README.md +38 -0
- kapsl-0.1.0/kapsl/__init__.py +50 -0
- kapsl-0.1.0/kapsl/_client.py +214 -0
- kapsl-0.1.0/kapsl/_tool.py +277 -0
- kapsl-0.1.0/kapsl.egg-info/PKG-INFO +55 -0
- kapsl-0.1.0/kapsl.egg-info/SOURCES.txt +11 -0
- kapsl-0.1.0/kapsl.egg-info/dependency_links.txt +1 -0
- kapsl-0.1.0/kapsl.egg-info/requires.txt +1 -0
- kapsl-0.1.0/kapsl.egg-info/top_level.txt +1 -0
- kapsl-0.1.0/pyproject.toml +22 -0
- kapsl-0.1.0/setup.cfg +4 -0
kapsl-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kapsl
|
|
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.
|
kapsl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kapsl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Kapsl agent runtime
|
|
5
|
+
Author-email: kapsl <kapsl.xyz@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://kapsl.xyz
|
|
8
|
+
Project-URL: Repository, https://github.com/kapsl-xyz/python-sdk
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: requests>=2.28
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# kapsl
|
|
19
|
+
|
|
20
|
+
Python SDK for the [Kapsl](https://kapsl.xyz) agent runtime.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install kapsl
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from kapsl import Agent, tool, Client
|
|
32
|
+
|
|
33
|
+
@tool
|
|
34
|
+
def search(query: str) -> str:
|
|
35
|
+
"""Search the web."""
|
|
36
|
+
return f"results for {query}"
|
|
37
|
+
|
|
38
|
+
agent = Agent(
|
|
39
|
+
name="researcher",
|
|
40
|
+
model="claude-sonnet-4-20250514",
|
|
41
|
+
system="You are a research assistant.",
|
|
42
|
+
tools=[search],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
client = Client()
|
|
46
|
+
client.deploy(agent)
|
|
47
|
+
|
|
48
|
+
run = client.run("researcher", "What is Kapsl?")
|
|
49
|
+
print(run.wait())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- Python 3.10+
|
|
55
|
+
- A running Kapsl daemon (`kapsl start`)
|
kapsl-0.1.0/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# kapsl
|
|
2
|
+
|
|
3
|
+
Python SDK for the [Kapsl](https://kapsl.xyz) agent runtime.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install kapsl
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from kapsl import Agent, tool, Client
|
|
15
|
+
|
|
16
|
+
@tool
|
|
17
|
+
def search(query: str) -> str:
|
|
18
|
+
"""Search the web."""
|
|
19
|
+
return f"results for {query}"
|
|
20
|
+
|
|
21
|
+
agent = Agent(
|
|
22
|
+
name="researcher",
|
|
23
|
+
model="claude-sonnet-4-20250514",
|
|
24
|
+
system="You are a research assistant.",
|
|
25
|
+
tools=[search],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
client = Client()
|
|
29
|
+
client.deploy(agent)
|
|
30
|
+
|
|
31
|
+
run = client.run("researcher", "What is Kapsl?")
|
|
32
|
+
print(run.wait())
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Python 3.10+
|
|
38
|
+
- A running Kapsl daemon (`kapsl start`)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Kapsl Python SDK — define tools, deploy agents, run them."""
|
|
2
|
+
|
|
3
|
+
from ._tool import tool
|
|
4
|
+
from ._client import Client, Run, KapslError
|
|
5
|
+
|
|
6
|
+
__all__ = ["tool", "Agent", "Client", "Run", "KapslError"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Agent:
|
|
10
|
+
"""An agent definition with tools, ready to deploy.
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
from kapsl import tool, Agent, Client
|
|
15
|
+
|
|
16
|
+
@tool
|
|
17
|
+
def search(query: str) -> str:
|
|
18
|
+
\"\"\"Search the web.\"\"\"
|
|
19
|
+
return f"results for {query}"
|
|
20
|
+
|
|
21
|
+
agent = Agent(name="researcher", system="You are a researcher.", tools=[search])
|
|
22
|
+
|
|
23
|
+
client = Client()
|
|
24
|
+
client.deploy(agent)
|
|
25
|
+
run = client.run("researcher", "Find info about Kapsl")
|
|
26
|
+
print(run.wait())
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
name: str,
|
|
32
|
+
*,
|
|
33
|
+
model: str = "claude-sonnet-4-20250514",
|
|
34
|
+
system: str | None = None,
|
|
35
|
+
tools: list | None = None,
|
|
36
|
+
requirements: list[str] | None = None,
|
|
37
|
+
):
|
|
38
|
+
self.name = name
|
|
39
|
+
self.model = model
|
|
40
|
+
self.system = system
|
|
41
|
+
self.tools = tools or []
|
|
42
|
+
self.requirements = requirements
|
|
43
|
+
|
|
44
|
+
# Validate all tools have the @tool decorator
|
|
45
|
+
for fn in self.tools:
|
|
46
|
+
if not getattr(fn, "_kapsl_tool", False):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Function '{fn.__name__}' is not decorated with @tool. "
|
|
49
|
+
f"Add @tool before the function definition."
|
|
50
|
+
)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Kapsl HTTP client — deploy agents, create runs, stream events."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from ._tool import _build_tool_defs, generate_tool_code
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("kapsl")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KapslError(Exception):
|
|
15
|
+
"""Error from the Kapsl API."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status_code: int, message: str):
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.message = message
|
|
20
|
+
super().__init__(f"[{status_code}] {message}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _default_on_event(event: dict) -> None:
|
|
24
|
+
"""Print step progress to stderr.
|
|
25
|
+
|
|
26
|
+
Event shape: {"event": "<kind>", "data": {"id": ..., "run_id": ..., "step": N, "kind": "...", "data": {...}}}
|
|
27
|
+
"""
|
|
28
|
+
kind = event.get("event", "")
|
|
29
|
+
payload = event.get("data", {})
|
|
30
|
+
step = payload.get("step", "")
|
|
31
|
+
inner = payload.get("data", {})
|
|
32
|
+
|
|
33
|
+
if kind == "step_started":
|
|
34
|
+
logger.info("step %s", step)
|
|
35
|
+
elif kind == "tool_call":
|
|
36
|
+
logger.info(" tool: %s", inner.get("name", "?"))
|
|
37
|
+
elif kind == "tool_result":
|
|
38
|
+
name = inner.get("name", "?")
|
|
39
|
+
ok = inner.get("ok", False)
|
|
40
|
+
if ok:
|
|
41
|
+
logger.info(" result: %s (ok)", name)
|
|
42
|
+
else:
|
|
43
|
+
logger.warning(" result: %s (error)", name)
|
|
44
|
+
elif kind == "run_completed":
|
|
45
|
+
logger.info("done")
|
|
46
|
+
elif kind == "run_failed":
|
|
47
|
+
logger.error("failed: %s", inner.get("error", "unknown error"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Run:
|
|
51
|
+
"""Handle to a running or completed run."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, data: dict, base_url: str):
|
|
54
|
+
self.id: str = data["id"]
|
|
55
|
+
self.status: str = data["status"]
|
|
56
|
+
self.output: Any = data.get("output")
|
|
57
|
+
self._data = data
|
|
58
|
+
self._base_url = base_url
|
|
59
|
+
|
|
60
|
+
def events(self):
|
|
61
|
+
"""Stream SSE events from the run. Yields dicts with 'event' and 'data' keys."""
|
|
62
|
+
url = f"{self._base_url}/v1/runs/{self.id}/events"
|
|
63
|
+
# read timeout only — no connect timeout so initial connection can take time,
|
|
64
|
+
# but if no data arrives for 60s the stream is considered dead.
|
|
65
|
+
resp = requests.get(url, stream=True, headers={"Accept": "text/event-stream"}, timeout=(10, 60))
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
|
|
68
|
+
event_type = None
|
|
69
|
+
data_buf = []
|
|
70
|
+
|
|
71
|
+
for raw_line in resp.iter_lines(decode_unicode=True):
|
|
72
|
+
if raw_line is None:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
line = raw_line
|
|
76
|
+
|
|
77
|
+
if line.startswith("event:"):
|
|
78
|
+
event_type = line[len("event:"):].strip()
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
if line.startswith("data:"):
|
|
82
|
+
data_buf.append(line[len("data:"):].strip())
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if line.startswith(":"):
|
|
86
|
+
# SSE comment / keepalive
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if line == "":
|
|
90
|
+
# Empty line = event boundary
|
|
91
|
+
if event_type and data_buf:
|
|
92
|
+
raw_data = "\n".join(data_buf)
|
|
93
|
+
try:
|
|
94
|
+
parsed = json.loads(raw_data)
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
parsed = raw_data
|
|
97
|
+
|
|
98
|
+
event = {"event": event_type, "data": parsed}
|
|
99
|
+
yield event
|
|
100
|
+
|
|
101
|
+
if event_type in ("run_completed", "run_failed", "run_suspended"):
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
event_type = None
|
|
105
|
+
data_buf = []
|
|
106
|
+
|
|
107
|
+
def refresh(self) -> "Run":
|
|
108
|
+
"""Re-fetch run state from the server."""
|
|
109
|
+
resp = requests.get(f"{self._base_url}/v1/runs/{self.id}")
|
|
110
|
+
if resp.status_code != 200:
|
|
111
|
+
raise KapslError(resp.status_code, resp.text)
|
|
112
|
+
data = resp.json()
|
|
113
|
+
self.status = data["status"]
|
|
114
|
+
self.output = data.get("output")
|
|
115
|
+
self._data = data
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def wait(self, on_event: Callable[[dict], None] | None = _default_on_event) -> Any:
|
|
119
|
+
"""Block until the run reaches a terminal state. Returns the output.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
on_event: Callback for each event. Pass None to suppress output.
|
|
123
|
+
Defaults to printing step progress to stderr.
|
|
124
|
+
"""
|
|
125
|
+
for event in self.events():
|
|
126
|
+
if on_event:
|
|
127
|
+
on_event(event)
|
|
128
|
+
self.refresh()
|
|
129
|
+
return self.output
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Client:
|
|
133
|
+
"""Kapsl API client."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, base_url: str = "http://127.0.0.1:50051"):
|
|
136
|
+
self.base_url = base_url.rstrip("/")
|
|
137
|
+
|
|
138
|
+
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
139
|
+
url = f"{self.base_url}{path}"
|
|
140
|
+
resp = requests.request(method, url, **kwargs)
|
|
141
|
+
if resp.status_code >= 400:
|
|
142
|
+
try:
|
|
143
|
+
body = resp.json()
|
|
144
|
+
msg = body.get("error", resp.text)
|
|
145
|
+
except Exception:
|
|
146
|
+
msg = resp.text
|
|
147
|
+
raise KapslError(resp.status_code, msg)
|
|
148
|
+
return resp
|
|
149
|
+
|
|
150
|
+
def deploy(self, agent) -> dict:
|
|
151
|
+
"""Deploy an agent to the runtime.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
agent: An Agent instance with name, model, system, and tools.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The created/updated agent dict from the API.
|
|
158
|
+
"""
|
|
159
|
+
payload: dict[str, Any] = {
|
|
160
|
+
"name": agent.name,
|
|
161
|
+
"model": agent.model,
|
|
162
|
+
}
|
|
163
|
+
if agent.system:
|
|
164
|
+
payload["system"] = agent.system
|
|
165
|
+
if agent.tools:
|
|
166
|
+
payload["tools"] = _build_tool_defs(agent.tools)
|
|
167
|
+
payload["tool_code"] = generate_tool_code(agent.tools)
|
|
168
|
+
if agent.requirements:
|
|
169
|
+
payload["requirements"] = agent.requirements
|
|
170
|
+
|
|
171
|
+
resp = self._request("POST", "/v1/agents", json=payload)
|
|
172
|
+
return resp.json()
|
|
173
|
+
|
|
174
|
+
def run(self, agent_name: str, input: Any, **overrides) -> Run:
|
|
175
|
+
"""Create a new run for a deployed agent.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
agent_name: Name of the deployed agent.
|
|
179
|
+
input: The input to send (string or JSON-serializable value).
|
|
180
|
+
**overrides: Optional per-run overrides (model, system, tools, tool_code).
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
A Run handle for streaming events and getting output.
|
|
184
|
+
"""
|
|
185
|
+
payload: dict[str, Any] = {"agent": agent_name, "input": input}
|
|
186
|
+
for key in ("model", "system", "tools", "tool_code"):
|
|
187
|
+
if key in overrides:
|
|
188
|
+
payload[key] = overrides[key]
|
|
189
|
+
|
|
190
|
+
resp = self._request("POST", "/v1/runs", json=payload)
|
|
191
|
+
return Run(resp.json(), self.base_url)
|
|
192
|
+
|
|
193
|
+
def get_run(self, run_id: str) -> dict:
|
|
194
|
+
"""Fetch a run by ID."""
|
|
195
|
+
return self._request("GET", f"/v1/runs/{run_id}").json()
|
|
196
|
+
|
|
197
|
+
def list_runs(self) -> list[dict]:
|
|
198
|
+
"""List all runs."""
|
|
199
|
+
return self._request("GET", "/v1/runs").json()
|
|
200
|
+
|
|
201
|
+
def list_agents(self) -> list[dict]:
|
|
202
|
+
"""List all registered agents."""
|
|
203
|
+
return self._request("GET", "/v1/agents").json()
|
|
204
|
+
|
|
205
|
+
def dry_run(self, tool_code: str, name: str, input: dict) -> dict:
|
|
206
|
+
"""Execute a single tool call without creating a run.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
tool_code: The complete Python tool script.
|
|
210
|
+
name: Tool function name to call.
|
|
211
|
+
input: Input dict for the tool.
|
|
212
|
+
"""
|
|
213
|
+
payload = {"tool_code": tool_code, "name": name, "input": input}
|
|
214
|
+
return self._request("POST", "/v1/tools/dry-run", json=payload).json()
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Tool decorator, JSON Schema generation, and tool code generation."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import inspect
|
|
5
|
+
import re
|
|
6
|
+
import textwrap
|
|
7
|
+
import typing
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def tool(fn):
|
|
12
|
+
"""Mark a function as a Kapsl tool.
|
|
13
|
+
|
|
14
|
+
Attaches metadata used by Agent and Client to build tool definitions
|
|
15
|
+
and generate the bundled tool_code script.
|
|
16
|
+
"""
|
|
17
|
+
hints = typing.get_type_hints(fn)
|
|
18
|
+
sig = inspect.signature(fn)
|
|
19
|
+
doc = inspect.getdoc(fn) or ""
|
|
20
|
+
|
|
21
|
+
fn._kapsl_tool = True
|
|
22
|
+
fn._kapsl_name = fn.__name__
|
|
23
|
+
fn._kapsl_description = doc.split("\n\n")[0].strip() # first paragraph
|
|
24
|
+
fn._kapsl_input_schema = _schema_from_hints(sig, hints, doc)
|
|
25
|
+
return fn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- Schema generation ---
|
|
29
|
+
|
|
30
|
+
_TYPE_MAP = {
|
|
31
|
+
str: {"type": "string"},
|
|
32
|
+
int: {"type": "integer"},
|
|
33
|
+
float: {"type": "number"},
|
|
34
|
+
bool: {"type": "boolean"},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_type(tp):
|
|
39
|
+
"""Convert a Python type annotation to a JSON Schema fragment."""
|
|
40
|
+
if tp in _TYPE_MAP:
|
|
41
|
+
return dict(_TYPE_MAP[tp])
|
|
42
|
+
|
|
43
|
+
# Bare list/dict without type args
|
|
44
|
+
if tp is list:
|
|
45
|
+
return {"type": "array"}
|
|
46
|
+
if tp is dict:
|
|
47
|
+
return {"type": "object"}
|
|
48
|
+
|
|
49
|
+
origin = typing.get_origin(tp)
|
|
50
|
+
args = typing.get_args(tp)
|
|
51
|
+
|
|
52
|
+
# list[X]
|
|
53
|
+
if origin is list:
|
|
54
|
+
item_type = args[0] if args else str
|
|
55
|
+
return {"type": "array", "items": _resolve_type(item_type)}
|
|
56
|
+
|
|
57
|
+
# dict[str, X]
|
|
58
|
+
if origin is dict:
|
|
59
|
+
val_type = args[1] if len(args) > 1 else str
|
|
60
|
+
return {"type": "object", "additionalProperties": _resolve_type(val_type)}
|
|
61
|
+
|
|
62
|
+
import warnings
|
|
63
|
+
warnings.warn(f"Unsupported type annotation {tp!r}, defaulting to string schema", stacklevel=3)
|
|
64
|
+
return {"type": "string"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_union(tp):
|
|
68
|
+
"""Check if a type is a Union (typing.Union or PEP 604 X | Y)."""
|
|
69
|
+
import types as _types
|
|
70
|
+
return typing.get_origin(tp) is typing.Union or isinstance(tp, _types.UnionType)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_optional(tp):
|
|
74
|
+
"""Check if a type is Optional[X] or X | None."""
|
|
75
|
+
if not _is_union(tp):
|
|
76
|
+
return False
|
|
77
|
+
return type(None) in typing.get_args(tp)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _unwrap_optional(tp):
|
|
81
|
+
"""Extract T from Optional[T]."""
|
|
82
|
+
args = typing.get_args(tp)
|
|
83
|
+
non_none = [a for a in args if a is not type(None)]
|
|
84
|
+
return non_none[0] if non_none else str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_arg_descriptions(doc: str) -> dict[str, str]:
|
|
88
|
+
"""Extract parameter descriptions from Google-style docstring Args section."""
|
|
89
|
+
descriptions = {}
|
|
90
|
+
in_args = False
|
|
91
|
+
current_param = None
|
|
92
|
+
current_desc = []
|
|
93
|
+
|
|
94
|
+
for line in doc.split("\n"):
|
|
95
|
+
stripped = line.strip()
|
|
96
|
+
if stripped.lower().startswith("args:"):
|
|
97
|
+
in_args = True
|
|
98
|
+
continue
|
|
99
|
+
if in_args:
|
|
100
|
+
# New section header (e.g., Returns:, Raises:)
|
|
101
|
+
if stripped and not stripped.startswith(" ") and stripped.endswith(":") and not stripped.startswith("-"):
|
|
102
|
+
# Check if it's indented relative to Args — if not, it's a new section
|
|
103
|
+
if not line.startswith(" " * 4) and not line.startswith("\t\t"):
|
|
104
|
+
break
|
|
105
|
+
# Parameter line: " param_name: description" or " param_name (type): description"
|
|
106
|
+
m = re.match(r"\s{2,}(\w+)(?:\s*\([^)]*\))?\s*:\s*(.*)", line)
|
|
107
|
+
if m:
|
|
108
|
+
if current_param:
|
|
109
|
+
descriptions[current_param] = " ".join(current_desc).strip()
|
|
110
|
+
current_param = m.group(1)
|
|
111
|
+
current_desc = [m.group(2)] if m.group(2) else []
|
|
112
|
+
elif current_param and stripped:
|
|
113
|
+
# Continuation line
|
|
114
|
+
current_desc.append(stripped)
|
|
115
|
+
elif not stripped:
|
|
116
|
+
if current_param:
|
|
117
|
+
descriptions[current_param] = " ".join(current_desc).strip()
|
|
118
|
+
current_param = None
|
|
119
|
+
current_desc = []
|
|
120
|
+
|
|
121
|
+
if current_param:
|
|
122
|
+
descriptions[current_param] = " ".join(current_desc).strip()
|
|
123
|
+
|
|
124
|
+
return descriptions
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _schema_from_hints(sig: inspect.Signature, hints: dict, doc: str) -> dict:
|
|
128
|
+
"""Build a JSON Schema object from function signature and type hints."""
|
|
129
|
+
properties = {}
|
|
130
|
+
required = []
|
|
131
|
+
arg_descs = _parse_arg_descriptions(doc)
|
|
132
|
+
|
|
133
|
+
for name, param in sig.parameters.items():
|
|
134
|
+
tp = hints.get(name)
|
|
135
|
+
if tp is None:
|
|
136
|
+
tp = str # no annotation → string
|
|
137
|
+
|
|
138
|
+
# Skip return type
|
|
139
|
+
if name == "return":
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
optional = _is_optional(tp)
|
|
143
|
+
if optional:
|
|
144
|
+
tp = _unwrap_optional(tp)
|
|
145
|
+
|
|
146
|
+
prop = _resolve_type(tp)
|
|
147
|
+
if name in arg_descs:
|
|
148
|
+
prop["description"] = arg_descs[name]
|
|
149
|
+
|
|
150
|
+
properties[name] = prop
|
|
151
|
+
|
|
152
|
+
# Required if: no default AND not Optional
|
|
153
|
+
if param.default is inspect.Parameter.empty and not optional:
|
|
154
|
+
required.append(name)
|
|
155
|
+
|
|
156
|
+
schema = {"type": "object", "properties": properties}
|
|
157
|
+
if required:
|
|
158
|
+
schema["required"] = required
|
|
159
|
+
return schema
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- Tool definitions (for API) ---
|
|
163
|
+
|
|
164
|
+
def _build_tool_defs(tools: list) -> list[dict]:
|
|
165
|
+
"""Build the tool definitions list for the Anthropic API."""
|
|
166
|
+
defs = []
|
|
167
|
+
for fn in tools:
|
|
168
|
+
defs.append({
|
|
169
|
+
"name": fn._kapsl_name,
|
|
170
|
+
"description": fn._kapsl_description,
|
|
171
|
+
"input_schema": fn._kapsl_input_schema,
|
|
172
|
+
})
|
|
173
|
+
return defs
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- Code generation ---
|
|
177
|
+
|
|
178
|
+
def _extract_imports(fn) -> list[str]:
|
|
179
|
+
"""Extract import statements from the module where fn is defined."""
|
|
180
|
+
try:
|
|
181
|
+
source_file = inspect.getfile(fn)
|
|
182
|
+
except (TypeError, OSError):
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
source = Path(source_file).read_text()
|
|
187
|
+
except (OSError, UnicodeDecodeError):
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
tree = ast.parse(source)
|
|
192
|
+
except SyntaxError:
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
imports = []
|
|
196
|
+
for node in ast.iter_child_nodes(tree):
|
|
197
|
+
if isinstance(node, ast.Import):
|
|
198
|
+
line = ast.get_source_segment(source, node)
|
|
199
|
+
if line:
|
|
200
|
+
imports.append(line)
|
|
201
|
+
elif isinstance(node, ast.ImportFrom):
|
|
202
|
+
# Skip kapsl imports
|
|
203
|
+
if node.module and node.module.startswith("kapsl"):
|
|
204
|
+
continue
|
|
205
|
+
line = ast.get_source_segment(source, node)
|
|
206
|
+
if line:
|
|
207
|
+
imports.append(line)
|
|
208
|
+
|
|
209
|
+
return imports
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _extract_function_source(fn) -> str:
|
|
213
|
+
"""Get the source of fn, stripping the @tool decorator line."""
|
|
214
|
+
source = inspect.getsource(fn)
|
|
215
|
+
source = textwrap.dedent(source)
|
|
216
|
+
lines = source.split("\n")
|
|
217
|
+
# Strip @tool decorator line(s)
|
|
218
|
+
filtered = []
|
|
219
|
+
skip_next = False
|
|
220
|
+
for line in lines:
|
|
221
|
+
stripped = line.strip()
|
|
222
|
+
if stripped.startswith("@tool"):
|
|
223
|
+
continue
|
|
224
|
+
filtered.append(line)
|
|
225
|
+
return "\n".join(filtered)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
_DISPATCH_LOOP = '''
|
|
229
|
+
_TOOLS = {%s}
|
|
230
|
+
|
|
231
|
+
import sys as _sys, json as _json
|
|
232
|
+
for _line in _sys.stdin:
|
|
233
|
+
_line = _line.strip()
|
|
234
|
+
if not _line:
|
|
235
|
+
continue
|
|
236
|
+
_req = _json.loads(_line)
|
|
237
|
+
_fn = _TOOLS.get(_req["name"])
|
|
238
|
+
if _fn is None:
|
|
239
|
+
print(_json.dumps({"error": f"tool \\'{_req['name']}\\' not found"}))
|
|
240
|
+
else:
|
|
241
|
+
try:
|
|
242
|
+
_result = _fn(**_req["input"])
|
|
243
|
+
_out = _result if isinstance(_result, (dict, list, str, int, float, bool, type(None))) else str(_result)
|
|
244
|
+
print(_json.dumps({"output": _out}))
|
|
245
|
+
except Exception as _e:
|
|
246
|
+
print(_json.dumps({"error": str(_e)}))
|
|
247
|
+
_sys.stdout.flush()
|
|
248
|
+
'''
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def generate_tool_code(tools: list) -> str:
|
|
252
|
+
"""Generate a complete, self-contained Python script for tool execution."""
|
|
253
|
+
# Collect imports from all tool modules (deduplicated)
|
|
254
|
+
seen_imports = set()
|
|
255
|
+
import_lines = []
|
|
256
|
+
for fn in tools:
|
|
257
|
+
for imp in _extract_imports(fn):
|
|
258
|
+
if imp not in seen_imports:
|
|
259
|
+
seen_imports.add(imp)
|
|
260
|
+
import_lines.append(imp)
|
|
261
|
+
|
|
262
|
+
# Collect function sources
|
|
263
|
+
func_sources = []
|
|
264
|
+
for fn in tools:
|
|
265
|
+
func_sources.append(_extract_function_source(fn))
|
|
266
|
+
|
|
267
|
+
# Build tool map string
|
|
268
|
+
tool_map = ", ".join(f'"{fn._kapsl_name}": {fn._kapsl_name}' for fn in tools)
|
|
269
|
+
|
|
270
|
+
parts = []
|
|
271
|
+
if import_lines:
|
|
272
|
+
parts.append("\n".join(import_lines))
|
|
273
|
+
parts.append("")
|
|
274
|
+
parts.append("\n\n".join(func_sources))
|
|
275
|
+
parts.append(_DISPATCH_LOOP % tool_map)
|
|
276
|
+
|
|
277
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kapsl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Kapsl agent runtime
|
|
5
|
+
Author-email: kapsl <kapsl.xyz@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://kapsl.xyz
|
|
8
|
+
Project-URL: Repository, https://github.com/kapsl-xyz/python-sdk
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: requests>=2.28
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# kapsl
|
|
19
|
+
|
|
20
|
+
Python SDK for the [Kapsl](https://kapsl.xyz) agent runtime.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install kapsl
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from kapsl import Agent, tool, Client
|
|
32
|
+
|
|
33
|
+
@tool
|
|
34
|
+
def search(query: str) -> str:
|
|
35
|
+
"""Search the web."""
|
|
36
|
+
return f"results for {query}"
|
|
37
|
+
|
|
38
|
+
agent = Agent(
|
|
39
|
+
name="researcher",
|
|
40
|
+
model="claude-sonnet-4-20250514",
|
|
41
|
+
system="You are a research assistant.",
|
|
42
|
+
tools=[search],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
client = Client()
|
|
46
|
+
client.deploy(agent)
|
|
47
|
+
|
|
48
|
+
run = client.run("researcher", "What is Kapsl?")
|
|
49
|
+
print(run.wait())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- Python 3.10+
|
|
55
|
+
- A running Kapsl daemon (`kapsl start`)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.28
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kapsl
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kapsl"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for the Kapsl agent runtime"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = ["requests>=2.28"]
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "kapsl", email = "kapsl.xyz@gmail.com" }]
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://kapsl.xyz"
|
|
18
|
+
Repository = "https://github.com/kapsl-xyz/python-sdk"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["setuptools>=68"]
|
|
22
|
+
build-backend = "setuptools.build_meta"
|
kapsl-0.1.0/setup.cfg
ADDED