agentpm 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.
agentpm/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """AgentPM Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ __all__ = ["load", "to_langchain_tool", "__version__"]
8
+
9
+ # Real exports
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ from .core import load
13
+
14
+ try:
15
+ __version__ = version("agentpm")
16
+ except PackageNotFoundError:
17
+ __version__ = "0.0.0"
18
+
19
+ # Tell type checkers that this symbol exists (no runtime import cost)
20
+ if TYPE_CHECKING:
21
+ from .adapters.langchain import to_langchain_tool as to_langchain_tool # re-exported type
22
+
23
+
24
+ # Lazy attribute for optional adapter (runtime)
25
+ def __getattr__(name: str) -> Any:
26
+ if name == "to_langchain_tool":
27
+ from .adapters.langchain import to_langchain_tool
28
+
29
+ return to_langchain_tool
30
+ raise AttributeError(name)
File without changes
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable, Mapping
5
+ from dataclasses import dataclass
6
+ from typing import TypedDict, TypeGuard
7
+
8
+ from ..types import JsonValue, LoadedWithMeta, ToolFunc, ToolMeta
9
+
10
+ # ---------- JSON Schema typing & guards ----------
11
+
12
+
13
+ class JsonSchemaProperty(TypedDict, total=False):
14
+ type: str
15
+
16
+
17
+ class JsonSchemaObject(TypedDict, total=False):
18
+ type: str
19
+ properties: dict[str, JsonSchemaProperty]
20
+ required: list[str]
21
+
22
+
23
+ def _is_json_schema_object(x: object) -> TypeGuard[JsonSchemaObject]:
24
+ if not isinstance(x, dict):
25
+ return False
26
+ if x.get("type") != "object":
27
+ return False
28
+ props = x.get("properties")
29
+ # ok if missing or a dict
30
+ return props is None or isinstance(props, dict)
31
+
32
+
33
+ def _is_record_json(x: object) -> TypeGuard[dict[str, JsonValue]]:
34
+ return isinstance(x, dict)
35
+
36
+
37
+ # ---------- Result mapping ----------
38
+
39
+
40
+ def _default_result_to_string(result: JsonValue, meta: ToolMeta | None) -> str:
41
+ outputs = meta.get("outputs") if meta else None
42
+ if _is_json_schema_object(outputs):
43
+ o = outputs
44
+ req = o.get("required")
45
+ key = req[0] if isinstance(req, list) and req else None
46
+ if isinstance(key, str):
47
+ props = o.get("properties") or {}
48
+ prop = props.get(key)
49
+ if isinstance(prop, dict) and prop.get("type") == "string" and _is_record_json(result):
50
+ val = result.get(key)
51
+ if isinstance(val, str):
52
+ return val
53
+ return result if isinstance(result, str) else json.dumps(result)
54
+
55
+
56
+ # ---------- Minimal adapter tool we return ----------
57
+
58
+
59
+ @dataclass
60
+ class _AdapterTool:
61
+ name: str
62
+ description: str
63
+ _call_structured: Callable[[Mapping[str, JsonValue]], str]
64
+
65
+ def invoke(self, args: Mapping[str, JsonValue]) -> str:
66
+ return self._call_structured(args)
67
+
68
+ def func(self, args: Mapping[str, JsonValue]) -> str:
69
+ return self._call_structured(args)
70
+
71
+ def __call__(self, args: Mapping[str, JsonValue]) -> str:
72
+ return self._call_structured(args)
73
+
74
+
75
+ # ---------- Public API ----------
76
+
77
+
78
+ def to_langchain_tool(
79
+ loaded: LoadedWithMeta,
80
+ *,
81
+ name: str | None = None,
82
+ description: str | None = None,
83
+ result_to_string: Callable[[JsonValue], str] | None = None,
84
+ force_simple: bool = False,
85
+ ) -> _AdapterTool:
86
+ # Enforce optional dependency presence
87
+ try:
88
+ import langchain_core.tools as _ # noqa: F401
89
+ except Exception as e: # pragma: no cover
90
+ raise ImportError(
91
+ "to_langchain_tool() requires langchain-core. Install with: pip install 'agentpm[langchain]'"
92
+ ) from e
93
+
94
+ tool_func: ToolFunc = loaded["func"]
95
+ meta: ToolMeta = loaded["meta"]
96
+
97
+ if not callable(tool_func):
98
+ raise TypeError("loaded['func'] must be callable")
99
+
100
+ tool_name: str = name or (meta.get("name") or "agentpm_tool")
101
+ desc_base: str = description or (meta.get("description") or "")
102
+
103
+ rich_desc = desc_base
104
+ if "inputs" in meta:
105
+ rich_desc += f" Inputs: {json.dumps(meta['inputs'])}."
106
+ if "outputs" in meta:
107
+ rich_desc += f" Outputs: {json.dumps(meta['outputs'])}."
108
+
109
+ r2s: Callable[[JsonValue], str] = result_to_string or (
110
+ lambda r: _default_result_to_string(r, meta)
111
+ )
112
+
113
+ inputs_schema = meta.get("inputs")
114
+ structured = _is_json_schema_object(inputs_schema) and not force_simple
115
+
116
+ if structured:
117
+
118
+ def call_structured(args: Mapping[str, JsonValue]) -> str:
119
+ res = tool_func(dict(args)) # plain dict[str, JsonValue]
120
+ return r2s(res)
121
+
122
+ return _AdapterTool(name=tool_name, description=rich_desc, _call_structured=call_structured)
123
+
124
+ # Simple path: coerce to object if schema hints at properties
125
+ def call_simple(input_like: Mapping[str, JsonValue]) -> str:
126
+ payload: JsonValue
127
+ if _is_json_schema_object(inputs_schema):
128
+ props = list((inputs_schema.get("properties") or {}).keys())
129
+ if "text" in props and isinstance(input_like.get("text"), str):
130
+ payload = {"text": input_like["text"]}
131
+ elif len(props) == 1:
132
+ key = props[0]
133
+ val = input_like.get(key)
134
+ # accept JSON-compatible values; else stringify
135
+ if val is None or isinstance(val, str | int | float | bool | dict | list):
136
+ payload = {key: val}
137
+ else:
138
+ payload = {key: str(val)}
139
+ else:
140
+ # Try common keys
141
+ for k in ("input", "value", "text"):
142
+ v = input_like.get(k)
143
+ if isinstance(v, str | int | float | bool):
144
+ payload = {"text": str(v)}
145
+ break
146
+ else:
147
+ payload = dict(input_like)
148
+ else:
149
+ if isinstance(input_like.get("text"), str):
150
+ payload = {"text": input_like["text"]}
151
+ else:
152
+ payload = dict(input_like)
153
+
154
+ res = tool_func(payload)
155
+ return r2s(res)
156
+
157
+ return _AdapterTool(name=tool_name, description=rich_desc, _call_structured=call_simple)
agentpm/core.py ADDED
@@ -0,0 +1,600 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import resource
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from collections.abc import Callable
12
+ from contextlib import suppress
13
+ from pathlib import Path
14
+ from typing import Literal, cast, overload
15
+
16
+ from semver import VersionInfo
17
+ from semver import match as semver_match
18
+
19
+ from .types import Entrypoint, JsonValue, LoadedWithMeta, Manifest, Runtime, ToolFunc, ToolMeta
20
+
21
+ DEFAULT_TIMEOUT = 120.0
22
+ _ALLOWED = {"node", "nodejs", "python", "python3"}
23
+
24
+
25
+ def _debug_enabled() -> bool:
26
+ val = os.getenv("AGENTPM_DEBUG", "")
27
+ return val not in ("", "0", "false", "False", "no")
28
+
29
+
30
+ def _dprint(msg: str) -> None:
31
+ if _debug_enabled():
32
+ sys.stderr.write(f"[agentpm-debug] {msg}\n")
33
+
34
+
35
+ def _abbrev(s: str, n: int = 240) -> str:
36
+ return s if len(s) <= n else (s[:n] + "…")
37
+
38
+
39
+ def _merge_env(
40
+ entry_env: dict[str, str] | None,
41
+ caller_env: dict[str, str] | None,
42
+ ) -> dict[str, str]:
43
+ merged = os.environ.copy()
44
+ if entry_env:
45
+ merged.update(entry_env)
46
+ if caller_env:
47
+ merged.update(caller_env)
48
+ return merged
49
+
50
+
51
+ def _canonical(cmd: str) -> str:
52
+ # handle absolute paths and Windows extensions
53
+ base = os.path.basename(cmd).lower()
54
+ for ext in (".exe", ".cmd", ".bat"):
55
+ if base.endswith(ext):
56
+ return base[: -len(ext)]
57
+ return base
58
+
59
+
60
+ def _interpreter_family(cmd: str) -> str | None:
61
+ base = os.path.basename(cmd).lower()
62
+ if base in ("node", "nodejs"):
63
+ return "node"
64
+ if base.startswith("python"):
65
+ return "python"
66
+ return None # absolute paths still get matched by basename
67
+
68
+
69
+ def _resolve_interpreter_command(
70
+ cmd: str,
71
+ entry_env: dict[str, str] | None,
72
+ caller_env: dict[str, str] | None,
73
+ runtime_type: str | None,
74
+ ) -> str:
75
+ merged = _merge_env(entry_env, caller_env)
76
+
77
+ # Prefer inferring from the command; fall back to runtime hint if needed
78
+ inferred = _interpreter_family(cmd)
79
+ hint = runtime_type if runtime_type == "node" or runtime_type == "python" else None
80
+ family = inferred or hint or None
81
+
82
+ if family == "node" and merged.get("AGENTPM_NODE"):
83
+ _dprint(f'override interpreter (node): "{cmd}" -> "{merged["AGENTPM_NODE"]}"')
84
+ return merged["AGENTPM_NODE"]
85
+ if family == "python" and merged.get("AGENTPM_PYTHON"):
86
+ _dprint(f'override interpreter (python): "{cmd}" -> "{merged["AGENTPM_PYTHON"]}"')
87
+ return merged["AGENTPM_PYTHON"]
88
+ return cmd
89
+
90
+
91
+ def _assert_allowed_interpreter(cmd: str) -> None:
92
+ canon = _canonical(cmd)
93
+ if canon not in _ALLOWED and not canon.startswith("pyhton3"):
94
+ raise ValueError(
95
+ f'Unsupported agent.json.entrypoint.command "{cmd}". Allowed: node|nodejs|python|python3'
96
+ )
97
+
98
+
99
+ # verify the interpreter exists on PATH
100
+ def _assert_interpreter_available(
101
+ cmd: str, entry_env: dict[str, str] | None, caller_env: dict[str, str] | None
102
+ ) -> None:
103
+ merged = _merge_env(entry_env, caller_env)
104
+
105
+ which = shutil.which(cmd, path=merged.get("PATH", ""))
106
+ _dprint(f'interpreter="{cmd}" which={which or "<not found>"}')
107
+ _dprint(f'MERGED PATH={_abbrev(merged.get("PATH",""))}')
108
+
109
+ if which is None:
110
+ raise FileNotFoundError(
111
+ f'Interpreter "{cmd}" not found on PATH.\nChecked PATH={merged.get("PATH","")}'
112
+ )
113
+
114
+
115
+ def _assert_interpreter_matches_runtime(cmd: str, runtime: Runtime) -> None:
116
+ canon = _canonical(cmd)
117
+ runtime_interpreter = _canonical(runtime["type"])
118
+
119
+ if not is_interpreter_match(runtime_interpreter, canon):
120
+ raise ValueError(
121
+ f'Misconfigured tool - agent.json.entrypoint.command "{cmd}" does not match tool runtime {runtime_interpreter}'
122
+ )
123
+
124
+
125
+ def is_interpreter_match(runtime: str, command: str) -> bool:
126
+ if runtime == command:
127
+ return True
128
+
129
+ # runtime -> acceptable command aliases
130
+ aliases = {"python": ["python3"], "node": ["nodejs"]}
131
+
132
+ return command in aliases.get(runtime, [])
133
+
134
+
135
+ def _list_installed_versions(base: Path, name: str) -> list[str]:
136
+ """Return all installed x.y.z versions for a tool name, searching all name dir variants."""
137
+ seen: set[str] = set()
138
+
139
+ for name_dir in candidate_name_dirs(str(base), name):
140
+ root = Path(name_dir)
141
+ if not root.is_dir():
142
+ continue
143
+
144
+ for child in root.iterdir():
145
+ if not child.is_dir():
146
+ continue
147
+
148
+ v = child.name
149
+ try:
150
+ # validate semver
151
+ VersionInfo.parse(v)
152
+ except ValueError:
153
+ continue
154
+
155
+ if (child / "agent.json").exists():
156
+ seen.add(v)
157
+
158
+ # highest first
159
+ return sorted(seen, key=VersionInfo.parse, reverse=True)
160
+
161
+
162
+ def candidate_name_dirs(base: str, name: str) -> list[str]:
163
+ """
164
+ Supports names like "@scope/name" or "scope/name".
165
+ Tries:
166
+ base/@scope/name, base/scope/name, base/scope__name, base/scope-name
167
+ Falls back to base/name for unscoped.
168
+ """
169
+ parts = name.split("/")
170
+
171
+ if len(parts) == 2:
172
+ raw_scope, pkg = parts
173
+ scope = raw_scope[1:] if raw_scope.startswith("@") else raw_scope
174
+ return [
175
+ os.path.join(base, f"@{scope}", pkg), # with '@'
176
+ os.path.join(base, scope, pkg), # without '@'
177
+ os.path.join(base, f"{scope}__{pkg}"),
178
+ os.path.join(base, f"{scope}-{pkg}"),
179
+ ]
180
+
181
+ # Unscoped package
182
+ return [os.path.join(base, name)]
183
+
184
+
185
+ def _find_installed(base: Path, name: str, version: str) -> tuple[Path, Path] | None:
186
+ """Return (root, manifest_path) if this exact version exists, searching all name dir variants."""
187
+ for name_dir in candidate_name_dirs(str(base), name):
188
+ root = Path(name_dir) / version
189
+ manifest = root / "agent.json"
190
+ if manifest.exists():
191
+ return root, manifest
192
+ return None
193
+
194
+
195
+ def find_project_root(start_dir: str | Path) -> Path:
196
+ """
197
+ Walk up from start_dir looking for project markers.
198
+ Priority: agent.json, package.json, pnpm-workspace.yaml, turbo.json, lerna.json, .git
199
+ Returns the resolved start_dir if nothing is found.
200
+ """
201
+ dir_path = Path(start_dir).resolve()
202
+ while True:
203
+ if (dir_path / "agent.json").exists():
204
+ return dir_path
205
+ if (dir_path / "package.json").exists():
206
+ return dir_path
207
+ if (dir_path / "pnpm-workspace.yaml").exists():
208
+ return dir_path
209
+ if (dir_path / "turbo.json").exists():
210
+ return dir_path
211
+ if (dir_path / "lerna.json").exists():
212
+ return dir_path
213
+ if (dir_path / ".git").exists():
214
+ return dir_path
215
+
216
+ parent = dir_path.parent
217
+ if parent == dir_path: # reached filesystem root
218
+ break
219
+ dir_path = parent
220
+
221
+ return Path(start_dir).resolve()
222
+
223
+
224
+ def _normalize_selector(selector: str) -> str:
225
+ s = selector.strip()
226
+ if not s or s.lower() == "latest":
227
+ return ""
228
+
229
+ def parts(ver: str) -> tuple[int, int, int, int]:
230
+ xs = [p for p in ver.strip().split(".") if p != ""]
231
+ n = len(xs)
232
+ maj = int(xs[0]) if n >= 1 else 0
233
+ min_ = int(xs[1]) if n >= 2 else 0
234
+ pat = int(xs[2]) if n >= 3 else 0
235
+ return maj, min_, pat, n
236
+
237
+ if s[0] in ("^", "~"):
238
+ op, base = s[0], s[1:].strip()
239
+ maj, min_, pat, n = parts(base)
240
+ lower = f">={maj}.{min_}.{pat}"
241
+ if op == "^":
242
+ if maj > 0:
243
+ upper = f"<{maj+1}.0.0"
244
+ elif n == 1:
245
+ upper = "<1.0.0" # ^0
246
+ elif min_ > 0:
247
+ upper = f"<0.{min_+1}.0" # ^0.y
248
+ else:
249
+ upper = f"<0.0.{pat+1}" # ^0.0.z
250
+ else: # '~'
251
+ upper = f"<{maj + 1}.0.0" if n == 1 else f"<{maj}.{min_ + 1}.0"
252
+ # return space-separated; we'll split on spaces/commas later
253
+ return f"{lower} {upper}"
254
+
255
+ # Comparator set like ">=0.1.1 <0.2.0" (or commas) → normalize whitespace
256
+ tokens = [t for t in s.replace(",", " ").split() if t]
257
+ return " ".join(tokens)
258
+
259
+
260
+ def _version_satisfies(ver: str, selector: str) -> bool:
261
+ expr = _normalize_selector(selector)
262
+ if not expr: # empty / "latest"
263
+ return True
264
+ # Split on spaces or commas
265
+ tokens = [t for t in re.split(r"[,\s]+", expr) if t]
266
+ try:
267
+ return all(semver_match(ver, tok) for tok in tokens)
268
+ except ValueError:
269
+ return False
270
+
271
+
272
+ def _resolve_tool_root(spec: str, tool_dir_override: str | None) -> tuple[Path, Path]:
273
+ # spec form: @scope/name@<version or range or 'latest'>
274
+ at = spec.rfind("@")
275
+ if at <= 0 or at == len(spec) - 1:
276
+ raise ValueError(f'Invalid tool spec "{spec}". Expected "@scope/name@version".')
277
+
278
+ selector = spec[at + 1 :].strip()
279
+
280
+ raw_name = spec[:at]
281
+ name = raw_name[1:] if raw_name.startswith("@") else raw_name # drop leading '@' if present
282
+
283
+ project_root = find_project_root(Path.cwd())
284
+ _dprint(f"project_root={project_root}")
285
+
286
+ # candidate search roots (project first)
287
+ candidates: list[Path] = []
288
+ if tool_dir_override:
289
+ candidates.append(Path(tool_dir_override))
290
+
291
+ env_dir = os.getenv("AGENTPM_TOOL_DIR")
292
+ if env_dir:
293
+ candidates.append(Path(env_dir))
294
+
295
+ candidates.append(project_root / ".agentpm" / "tools")
296
+ candidates.append(Path.home() / ".agentpm" / "tools")
297
+
298
+ _dprint("candidates:\n " + "\n ".join(str(c) for c in candidates))
299
+
300
+ # 1) Exact version fast path
301
+ try:
302
+ if selector and selector.lower() != "latest":
303
+ VersionInfo.parse(selector) # raises if not exact x.y.z
304
+ for base in candidates:
305
+ hit = _find_installed(base, name, selector)
306
+ if hit:
307
+ return hit
308
+ raise FileNotFoundError(f'Tool "{spec}" not found in .agentpm/tools (or overrides).')
309
+ except ValueError:
310
+ # not an exact version → fall through to range/latest
311
+ pass
312
+
313
+ # 2) Range or "latest" (or empty after "@")
314
+ want_latest = (not selector) or (selector.lower() == "latest")
315
+
316
+ for base in candidates:
317
+ installed = _list_installed_versions(base, name)
318
+ if not installed:
319
+ continue
320
+
321
+ if want_latest:
322
+ picked = installed[0]
323
+ hit = _find_installed(base, name, picked)
324
+ if hit:
325
+ return hit
326
+ continue
327
+
328
+ # Filter by range using semver.match, then pick highest
329
+ satisfying: list[str] = []
330
+ for v in installed:
331
+ if _version_satisfies(v, selector):
332
+ satisfying.append(v)
333
+
334
+ if satisfying:
335
+ picked = sorted(satisfying, key=VersionInfo.parse, reverse=True)[0]
336
+ hit = _find_installed(base, name, picked)
337
+ if hit:
338
+ return hit
339
+
340
+ searched = ", ".join(str(c) for c in candidates)
341
+ raise FileNotFoundError(
342
+ f'No installed version of "{name}" matches "{selector or "latest"}". Searched: {searched}'
343
+ )
344
+
345
+
346
+ def _read_manifest(p: Path) -> Manifest:
347
+ m = json.loads(p.read_text(encoding="utf-8"))
348
+ ep = m.get("entrypoint", {})
349
+ if not ep or not ep.get("command"):
350
+ raise ValueError(f"agent.json missing entrypoint.command at: {p}")
351
+ return m # type: ignore[no-any-return]
352
+
353
+
354
+ def _build_env(
355
+ entry_env: dict[str, str], caller_env: dict[str, str], home: str, tmpdir: str
356
+ ) -> dict[str, str]:
357
+ base = {
358
+ "PATH": os.environ.get("PATH", ""),
359
+ "HOME": home,
360
+ "TMPDIR": tmpdir,
361
+ }
362
+ # Optional: preserve locale if present
363
+ for k in ("LANG", "LC_ALL"):
364
+ if k in os.environ:
365
+ base[k] = os.environ[k]
366
+ # Agent-provided env wins, then caller overrides
367
+ return {**base, **entry_env, **caller_env}
368
+
369
+
370
+ def _preexec_rlimits_for(
371
+ cmd: str,
372
+ *,
373
+ max_cpu_s: int | None = 10,
374
+ max_files: int | None = 512,
375
+ max_addr_mb: int | None = 512,
376
+ ) -> Callable[[], None]:
377
+ """
378
+ Apply rlimits safely per interpreter.
379
+
380
+ - Node (node/nodejs): SKIP RLIMIT_AS by default (V8 JIT/WASM need large VA space).
381
+ You can force a value with env AGENTPM_RLIMIT_AS_MB.
382
+ - Python: keep modest RLIMIT_AS if you want.
383
+ """
384
+ import os
385
+ import resource # type: ignore
386
+
387
+ IS_DARWIN = os.uname().sysname == "Darwin"
388
+ fam = _canonical(cmd)
389
+ is_node = fam in ("node", "nodejs")
390
+
391
+ # Optional global override
392
+ env_override = os.getenv("AGENTPM_RLIMIT_AS_MB")
393
+ addr_mb = max_addr_mb
394
+ if env_override:
395
+ with suppress(ValueError):
396
+ parsed = int(env_override)
397
+ if parsed > 0:
398
+ addr_mb = parsed
399
+
400
+ # Default: do NOT cap address space for Node
401
+ if is_node and env_override is None:
402
+ addr_mb = None
403
+
404
+ _dprint(
405
+ f"rlimits: cmd={cmd} RLIMIT_AS={'off' if is_node and env_override is None else addr_mb}MB"
406
+ )
407
+
408
+ def _fn() -> None:
409
+ if max_cpu_s is not None and hasattr(resource, "RLIMIT_CPU"):
410
+ resource.setrlimit(resource.RLIMIT_CPU, (max_cpu_s, max_cpu_s))
411
+ if max_files is not None and hasattr(resource, "RLIMIT_NOFILE"):
412
+ resource.setrlimit(resource.RLIMIT_NOFILE, (max_files, max_files))
413
+ if addr_mb is not None and not IS_DARWIN and hasattr(resource, "RLIMIT_AS"):
414
+ limit = addr_mb * 1024 * 1024
415
+ resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
416
+
417
+ return _fn
418
+
419
+
420
+ def _spawn_once(
421
+ root: Path, entry: Entrypoint, payload: JsonValue, timeout_s: float, env: dict[str, str]
422
+ ) -> JsonValue:
423
+ # 1) Tool working dir (what the tool expects for relative paths)
424
+ tool_cwd = (root / entry.get("cwd", ".")).resolve()
425
+
426
+ # 2) Isolated run dirs for HOME/TMPDIR
427
+ run_root: Path = tool_cwd / "run"
428
+ run_root.mkdir(parents=True, exist_ok=True)
429
+ work = Path(tempfile.mkdtemp(prefix="run-", dir=str(run_root)))
430
+ home = str(work / "home")
431
+ Path(home).mkdir(parents=True, exist_ok=True)
432
+ tmpd = str(work / "tmp")
433
+ Path(tmpd).mkdir(parents=True, exist_ok=True)
434
+
435
+ # 3) Clean env
436
+ env = _build_env(entry.get("env", {}), env, home, tmpd)
437
+
438
+ # 4) Command + hardening flags
439
+ cmd = [entry["command"], *entry.get("args", [])]
440
+ if _canonical(entry["command"]).startswith("python"):
441
+ if "-I" not in cmd:
442
+ cmd.insert(1, "-I")
443
+ if "-B" not in cmd:
444
+ cmd.insert(1, "-B")
445
+ elif _canonical(entry["command"]).startswith("node"):
446
+ old_space = int(env.get("AGENTPM_NODE_OLD_SPACE_MB", "256"))
447
+
448
+ if not any(a.startswith("--max-old-space-size") for a in cmd[1:]):
449
+ cmd.insert(1, f"--max-old-space-size={old_space}")
450
+
451
+ want_jitless = (
452
+ any(a == "--jitless" for a in cmd[1:])
453
+ or "--jitless" in (env or {}).get("NODE_OPTIONS", "")
454
+ or env.get("AGENTPM_NODE_JITLESS", "").lower() in ("1", "true", "yes")
455
+ )
456
+ if want_jitless and "--jitless" not in cmd[1:]:
457
+ cmd.insert(1, "--jitless")
458
+
459
+ _dprint(f"launch: argv={cmd}")
460
+ _dprint(f"cwd={tool_cwd}")
461
+ # _dprint(f"env={env}")
462
+ # 5) Spawn (cwd = tool_cwd)
463
+ proc = subprocess.Popen(
464
+ cmd,
465
+ cwd=str(tool_cwd),
466
+ env=env,
467
+ stdin=subprocess.PIPE,
468
+ stdout=subprocess.PIPE,
469
+ stderr=subprocess.PIPE,
470
+ text=True,
471
+ start_new_session=True,
472
+ preexec_fn=(
473
+ _preexec_rlimits_for(entry["command"]) if hasattr(resource, "setrlimit") else None
474
+ ),
475
+ )
476
+ try:
477
+ stdout, stderr = proc.communicate(input=json.dumps(payload), timeout=timeout_s)
478
+ except subprocess.TimeoutExpired as e:
479
+ proc.kill()
480
+ raise TimeoutError(f"Tool timed out after {timeout_s:.1f}s") from e
481
+
482
+ # Cap outputs (10MB)
483
+ max_bytes = 10 * 1024 * 1024
484
+ if len(stdout.encode("utf-8")) + len(stderr.encode("utf-8")) > max_bytes:
485
+ raise RuntimeError("Tool produced too much output; limit is 10MB")
486
+
487
+ if proc.returncode != 0:
488
+ # Save full streams for inspection and KEEP the run dir on error
489
+ try:
490
+ (work / "child.stdout").write_text(stdout or "", encoding="utf-8")
491
+ (work / "child.stderr").write_text(stderr or "", encoding="utf-8")
492
+ except Exception:
493
+ pass
494
+ _dprint(f"[agentpm] child logs saved in: {work}")
495
+
496
+ tail = stderr[-4000:] if stderr else ""
497
+ raise RuntimeError(f"Tool exited with code {proc.returncode}. Stderr (tail):\n{tail}")
498
+
499
+ try:
500
+ return _extract_last_json(stdout)
501
+ except Exception as e:
502
+ raise RuntimeError(
503
+ f"Failed to parse tool JSON output.\nStderr:\n{stderr}\nStdout:\n{stdout}\nReason: {e}"
504
+ ) from e
505
+ finally:
506
+ shutil.rmtree(work, ignore_errors=True)
507
+
508
+
509
+ def _extract_last_json(text: str) -> JsonValue:
510
+ # naive but effective: scan for last '{' and try parse json from there.
511
+ idx = text.rfind("{")
512
+ if idx < 0:
513
+ raise RuntimeError("No JSON object found on stdout.")
514
+ return json.loads(text[idx:]) # type: ignore[no-any-return]
515
+
516
+
517
+ # --- Overloads (type-only) ---
518
+ @overload
519
+ def load(
520
+ spec: str,
521
+ with_meta: Literal[True],
522
+ timeout: float | None = ...,
523
+ tool_dir_override: str | None = ...,
524
+ env: dict[str, str] | None = ...,
525
+ ) -> LoadedWithMeta: ...
526
+ @overload
527
+ def load(
528
+ spec: str,
529
+ with_meta: Literal[False] = ...,
530
+ timeout: float | None = ...,
531
+ tool_dir_override: str | None = ...,
532
+ env: dict[str, str] | None = ...,
533
+ ) -> ToolFunc: ...
534
+
535
+
536
+ def load(
537
+ spec: str,
538
+ with_meta: bool = False,
539
+ timeout: float | None = None,
540
+ tool_dir_override: str | None = None,
541
+ env: dict[str, str] | None = None,
542
+ ) -> ToolFunc | LoadedWithMeta:
543
+ _dprint(f"spec={spec}")
544
+
545
+ root, manifest_path = _resolve_tool_root(spec, tool_dir_override)
546
+ m = _read_manifest(manifest_path)
547
+
548
+ env = env or {}
549
+
550
+ # enforce interpreter whitelist and available
551
+ ep = m["entrypoint"]
552
+
553
+ _dprint(f"resolved root={root}")
554
+ _dprint(f"manifest={manifest_path}")
555
+ _dprint(f'entry.command="{ep["command"]}" args={ep.get("args", [])}')
556
+
557
+ runtime = m.get("runtime") or {}
558
+ rt = runtime.get("type")
559
+ runtime_type: str | None = rt if rt in ("node", "python") else None
560
+ resolved_cmd = _resolve_interpreter_command(ep["command"], ep.get("env", {}), env, runtime_type)
561
+
562
+ # enforce interpreter whitelist and available
563
+ _assert_allowed_interpreter(resolved_cmd)
564
+ _assert_interpreter_available(resolved_cmd, ep.get("env", {}), env)
565
+
566
+ # enforce interpreter and runtime compatability
567
+ if "runtime" in m and "type" in m["runtime"]:
568
+ _assert_interpreter_matches_runtime(resolved_cmd, m["runtime"])
569
+
570
+ t_s = (
571
+ timeout
572
+ if timeout is not None
573
+ else float(ep.get("timeout_ms") or (DEFAULT_TIMEOUT * 1000)) / 1000.0
574
+ )
575
+
576
+ entry_for_spawn = ep | {"command": resolved_cmd}
577
+
578
+ def func(input: JsonValue) -> JsonValue:
579
+ return _spawn_once(root, entry_for_spawn, input, t_s, env)
580
+
581
+ if with_meta:
582
+ name = m["name"]
583
+ version = m["version"]
584
+
585
+ meta: ToolMeta = {
586
+ "name": name,
587
+ "version": version,
588
+ }
589
+
590
+ desc = m.get("description")
591
+ if isinstance(desc, str):
592
+ meta["description"] = desc
593
+ if "inputs" in m:
594
+ meta["inputs"] = cast(JsonValue, m["inputs"])
595
+ if "outputs" in m:
596
+ meta["outputs"] = cast(JsonValue, m["outputs"])
597
+
598
+ return {"func": func, "meta": meta}
599
+
600
+ return func
agentpm/py.typed ADDED
File without changes
agentpm/types.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import NotRequired, Required, TypedDict
5
+
6
+ JsonPrimitive = str | int | float | bool | None
7
+ JsonValue = JsonPrimitive | dict[str, "JsonValue"] | list["JsonValue"]
8
+
9
+
10
+ class ToolMeta(TypedDict, total=False):
11
+ name: Required[str]
12
+ version: Required[str]
13
+ description: NotRequired[str]
14
+ inputs: NotRequired[JsonValue]
15
+ outputs: NotRequired[JsonValue]
16
+ runtime: NotRequired[Runtime]
17
+
18
+
19
+ class Runtime(TypedDict, total=False):
20
+ type: str
21
+ version: str
22
+
23
+
24
+ class Entrypoint(TypedDict, total=False):
25
+ command: str
26
+ args: list[str]
27
+ cwd: str
28
+ timeout_ms: int
29
+ env: dict[str, str]
30
+
31
+
32
+ class Manifest(ToolMeta, total=False):
33
+ entrypoint: Entrypoint
34
+
35
+
36
+ ToolFunc = Callable[[JsonValue], JsonValue]
37
+
38
+
39
+ class LoadedWithMeta(TypedDict):
40
+ func: ToolFunc
41
+ meta: ToolMeta
@@ -0,0 +1,336 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentpm
3
+ Version: 0.1.0
4
+ Summary: AgentPM Python SDK
5
+ Project-URL: Homepage, https://github.com/agentpm-dev/sdk-python
6
+ Project-URL: Repository, https://github.com/agentpm-dev/sdk-python
7
+ Project-URL: Issues, https://github.com/agentpm-dev/sdk-python/issues
8
+ Project-URL: Changelog, https://github.com/agentpm-dev/sdk-python/releases
9
+ Author-email: AgentPM <dev@agentpm.dev>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2025 Zack Hine
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the “Software”), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in
22
+ all copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
30
+ THE SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: agentpm,agents,ai,tools
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3 :: Only
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Typing :: Typed
40
+ Requires-Python: >=3.10
41
+ Requires-Dist: semver>=3.0.0
42
+ Provides-Extra: dev
43
+ Requires-Dist: black>=24.8.0; extra == 'dev'
44
+ Requires-Dist: build>=1.2.1; extra == 'dev'
45
+ Requires-Dist: mypy>=1.11.0; extra == 'dev'
46
+ Requires-Dist: pre-commit>=3.8.0; extra == 'dev'
47
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
48
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
49
+ Requires-Dist: ruff>=0.6.8; extra == 'dev'
50
+ Requires-Dist: twine>=5.1.1; extra == 'dev'
51
+ Provides-Extra: langchain
52
+ Requires-Dist: langchain-core>=0.2.0; extra == 'langchain'
53
+ Description-Content-Type: text/markdown
54
+
55
+ # AgentPM Python SDK
56
+
57
+ A lean, typed Python SDK for **AgentPM** tools. It discovers tools installed by `agentpm install`, executes their entrypoints in a subprocess, and returns JSON results you can pass to your agents.
58
+
59
+ - 🔎 **Discovers** tools in `.agentpm/tools` (project) and `~/.agentpm/tools` (user), with `AGENTPM_TOOL_DIR` override.
60
+ - 🚀 **Runs entrypoints** via `node` or `python` (whitelisted) and exchanges JSON over stdin/stdout.
61
+ - 🧩 **Metadata-aware**: `with_meta=True` returns `func + meta` (name, version, description, inputs, outputs).
62
+ - 🧪 **Framework adapters (optional)**: e.g., a LangChain adapter you can use if installed.
63
+
64
+ > Requires Python **3.10+**.
65
+
66
+ ---
67
+
68
+ ## Installation
69
+
70
+ ### From PyPI (recommended)
71
+
72
+ Using **uv**:
73
+ ```bash
74
+ uv pip install agentpm
75
+ ```
76
+
77
+ Or with standard pip:
78
+ ```bash
79
+ python -m pip install agentpm
80
+ ```
81
+
82
+ If you'll use the optional LangChain adapter:
83
+ ```bash
84
+ uv pip install 'agentpm[langchain]'
85
+ # or
86
+ python -m pip install 'agentpm[langchain]'
87
+ ```
88
+ ---
89
+
90
+ ## Quick Start (with `uv`)
91
+
92
+ ```bash
93
+ # create and activate a venv
94
+ uv venv
95
+ source .venv/bin/activate
96
+
97
+ # install SDK in editable dev mode (ruff/black/mypy/pytest, etc.)
98
+ uv pip install -e ".[dev]"
99
+
100
+ # sanity checks
101
+ uv run ruff check .
102
+ uv run black --check .
103
+ uv run mypy
104
+ uv run pytest -q
105
+ ```
106
+
107
+ > If you're not using `uv`, standard `python -m venv` + `pip install -e ".[dev]"` works too.
108
+
109
+ ---
110
+
111
+ ## Using the SDK
112
+
113
+ ```python
114
+ from agentpm import load
115
+
116
+ # Spec format: "@scope/name@version"
117
+ summarize = load("@zack/summarize@0.1.0")
118
+
119
+ result = summarize({"text": "Long document content..."})
120
+ print(result["summary"])
121
+ ```
122
+
123
+ ### With metadata (build richer tool descriptions)
124
+ ```python
125
+ from agentpm import load
126
+
127
+ tool = load("@zack/summarize@0.1.0", with_meta=True)
128
+ summarize, meta = tool["func"], tool["meta"]
129
+
130
+ rich_description = (
131
+ f"{meta.get('description','')} "
132
+ f"Inputs: {meta.get('inputs')}. "
133
+ f"Outputs: {meta.get('outputs')}."
134
+ )
135
+
136
+ print(rich_description)
137
+ print(summarize({"text": "hello"})["summary"])
138
+ ```
139
+
140
+ ### Optional: LangChain adapter
141
+ The adapter is lazy-imported and only needed if you call it.
142
+
143
+ ```python
144
+ from agentpm import load, to_langchain_tool # to_langchain_tool is loaded on first access
145
+
146
+ loaded = load("@zack/summarize@0.1.0", with_meta=True)
147
+ tool = to_langchain_tool(loaded) # requires `langchain-core` installed
148
+ ```
149
+
150
+ If you use the adapter, install LangChain core:
151
+
152
+ ```bash
153
+ uv pip install langchain-core
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Where tools are discovered
159
+
160
+ Resolution order:
161
+
162
+ 1. `AGENTPM_TOOL_DIR` (environment variable)
163
+ 2. `./.agentpm/tools` (project-local)
164
+ 3. `~/.agentpm/tools` (user-local)
165
+
166
+ Each tool lives in a directory like:
167
+
168
+ ```
169
+ .agentpm/
170
+ tools/
171
+ @zack/summarize/
172
+ 0.1.0/
173
+ agent.json
174
+ (tool files…)
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Manifest & Runtime Contract
180
+
181
+ **`agent.json` (minimal fields used by the SDK):**
182
+ ```json
183
+ {
184
+ "name": "@zack/summarize",
185
+ "version": "0.1.0",
186
+ "description": "Summarize long text.",
187
+ "inputs": {
188
+ "type": "object",
189
+ "properties": { "text": { "type": "string", "description": "Text to summarize" } },
190
+ "required": ["text"]
191
+ },
192
+ "outputs": {
193
+ "type": "object",
194
+ "properties": { "summary": { "type": "string", "description": "Summarized text" } },
195
+ "required": ["summary"]
196
+ },
197
+ "entrypoint": {
198
+ "command": "python",
199
+ "args": ["main.py"],
200
+ "cwd": ".",
201
+ "timeout_ms": 60000,
202
+ "env": {}
203
+ }
204
+ }
205
+ ```
206
+
207
+ **Execution contract:**
208
+ - SDK writes **inputs JSON** to the process **stdin**.
209
+ - Tool writes a single **outputs JSON** object to **stdout**.
210
+ - Non-JSON logs should go to **stderr**.
211
+ - Process must exit with **code 0** on success.
212
+
213
+ **Interpreter whitelist:** `node`, `nodejs`, `python`, `python3`.
214
+ The SDK validates the interpreter and checks it’s present on `PATH`.
215
+
216
+ ---
217
+
218
+ ## Development
219
+
220
+ ### Project layout
221
+ ```
222
+ src/
223
+ agentpm/
224
+ __init__.py # re-exports: load, to_langchain_tool (lazy)
225
+ core.py # resolver/spawn/JSON plumbing
226
+ types.py # JsonValue, TypedDicts
227
+ adapters/
228
+ __init__.py
229
+ langchain.py # optional adapter
230
+ py.typed # marks package as typed
231
+ tests/
232
+ test_basic.py
233
+ ```
234
+
235
+ ### Common tasks (via `uv`)
236
+ ```bash
237
+ uv run ruff check .
238
+ uv run black --check .
239
+ uv run mypy
240
+ uv run pytest -q
241
+
242
+ # run hooks locally on all files
243
+ uv run pre-commit run --all-files
244
+ ```
245
+
246
+ ---
247
+
248
+ ## Building & Publishing
249
+
250
+ ```bash
251
+ # build wheel & sdist
252
+ uv run python -m build
253
+
254
+ # verify metadata
255
+ uv run twine check dist/*
256
+
257
+ # upload (PyPI)
258
+ uv run twine upload dist/*
259
+
260
+ # or TestPyPI first
261
+ uv run twine upload -r testpypi dist/*
262
+ ```
263
+
264
+ ---
265
+
266
+ ## Running mixed-runtime Agent apps with Docker
267
+
268
+ Some AgentPM tools run on Node, some on Python—and your agent may need to spawn both. Using Docker gives you a single, reproducible environment where both interpreters are installed and on PATH, which avoids the common “interpreter not found” issues that pop up on PaaS/CI or IDEs.
269
+
270
+ Why Docker?
271
+
272
+ ✅ Hermetic: Python + Node versions are pinned inside the image.
273
+
274
+ ✅ No PATH drama: node/python are present and discoverable.
275
+
276
+ ✅ Prod/CI parity: the same image runs on your laptop, CI, and servers.
277
+
278
+ ✅ Easy secrets: pass API keys via env at docker run/Compose time.
279
+
280
+ ✅ Fewer surprises: consistent OS libs for LLM clients, SSL, etc.
281
+
282
+ ### When to use it
283
+
284
+ - You deploy to platforms that don’t let you apt-get both runtimes.
285
+ - Your agent uses tools with different interpreters (Node + Python).
286
+ - Your local dev/IDE PATH differs from production and causes failures.
287
+ - You want reproducible builds and easy rollback.
288
+
289
+ ### How to use it
290
+
291
+ 1. Copy the provided [Dockerfile](https://github.com/agentpm-dev/sdk-python/tree/main/examples/python-agent) into your repo.
292
+ 2. (Optional) Pre-install tools locally with agentpm install ... and commit or copy .agentpm/tools/ into the image, or run agentpm install at build time if your CLI is available in the image.
293
+ 3. Build & run:
294
+
295
+ ```bash
296
+ docker build -t agent-app .
297
+ docker run --rm -e OPENAI_API_KEY=$OPENAI_API_KEY agent-app
298
+ ```
299
+
300
+ 4. For development, use the docker-compose.yml snippet to mount your source and pass env vars conveniently.
301
+
302
+ ### Troubleshooting
303
+
304
+ - Set `AGENTPM_DEBUG=1` to print the SDK’s project root, search paths, merged PATH, and resolved interpreters.
305
+ - You can force interpreters via:
306
+ ```ini
307
+ AGENTPM_NODE=/usr/bin/node
308
+ AGENTPM_PYTHON=/usr/local/bin/python3.11
309
+ ```
310
+
311
+ - Prefer absolute interpreters in agent.json.entrypoint.command for production (e.g., /usr/bin/node). The SDKs still enforce the Node/Python family.
312
+
313
+ ---
314
+
315
+ ## Troubleshooting
316
+
317
+ - **`No JSON object found on stdout.`**
318
+ Ensure your tool prints a single JSON object as the last thing on stdout, and writes logs to stderr.
319
+
320
+ - **`Unsupported agent.json.entrypoint.command`**
321
+ Only `node` / `python` are allowed (including `nodejs` / `python3`). Update `entrypoint.command`.
322
+
323
+ - **`Interpreter "... " not found on PATH`**
324
+ Install the interpreter or adjust `entrypoint.command`. The SDK runs `<command> --version` to verify availability.
325
+
326
+ - **PEP 668 / “externally managed”**
327
+ Use a venv (we recommend `uv venv`) and install with `uv pip install -e ".[dev]"`.
328
+
329
+ - **IDE can’t import `agentpm`**
330
+ Ensure your interpreter is the project’s `.venv/bin/python`, and that you ran the editable install.
331
+
332
+ ---
333
+
334
+ ## License
335
+
336
+ MIT — see `LICENSE`.
@@ -0,0 +1,10 @@
1
+ agentpm/__init__.py,sha256=kmS34PbRFkkgXCrV7o8YQ8EvFg-wAPk8-q8ouOsRdek,784
2
+ agentpm/core.py,sha256=PaF-KEeUYmg4-m_4zshZP7Zxp7-mLObjO6BQKJf10nA,19411
3
+ agentpm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ agentpm/types.py,sha256=8kKv6MD-cfVEMRUeU-tEEftx-ggFQMGATgvgXkJAu0c,874
5
+ agentpm/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ agentpm/adapters/langchain.py,sha256=k812eo325LdE76TjPiFPkuPfpcZSMEOS1QSHnKswo8g,5204
7
+ agentpm-0.1.0.dist-info/METADATA,sha256=mdxwdrDoD_iv-a8ruBWRLxIRt17skMPQmBkeIrB0QHs,9880
8
+ agentpm-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ agentpm-0.1.0.dist-info/licenses/LICENSE,sha256=8uYxF1GbxKhtQUFq4nHvYXzglvMsqlF-ZpMQjKkCxTI,1074
10
+ agentpm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Zack Hine
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
13
+ all 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
21
+ THE SOFTWARE.