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 +30 -0
- agentpm/adapters/__init__.py +0 -0
- agentpm/adapters/langchain.py +157 -0
- agentpm/core.py +600 -0
- agentpm/py.typed +0 -0
- agentpm/types.py +41 -0
- agentpm-0.1.0.dist-info/METADATA +336 -0
- agentpm-0.1.0.dist-info/RECORD +10 -0
- agentpm-0.1.0.dist-info/WHEEL +4 -0
- agentpm-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|