usegradient 1.3.1__tar.gz → 1.3.2__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.
Files changed (33) hide show
  1. {usegradient-1.3.1 → usegradient-1.3.2}/PKG-INFO +1 -2
  2. {usegradient-1.3.1 → usegradient-1.3.2}/README.md +0 -1
  3. {usegradient-1.3.1 → usegradient-1.3.2}/examples/sdk_first_agent.py +1 -1
  4. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/__init__.py +2 -0
  5. usegradient-1.3.2/gradient_sdk/dependencies.py +217 -0
  6. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/integrations/agent.py +96 -5
  7. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/runtime.py +163 -50
  8. {usegradient-1.3.1 → usegradient-1.3.2}/pyproject.toml +1 -1
  9. {usegradient-1.3.1 → usegradient-1.3.2}/tests/test_agent.py +8 -1
  10. usegradient-1.3.2/tests/test_dependencies.py +71 -0
  11. {usegradient-1.3.1 → usegradient-1.3.2}/tests/test_runtime.py +24 -6
  12. {usegradient-1.3.1 → usegradient-1.3.2}/.gitignore +0 -0
  13. {usegradient-1.3.1 → usegradient-1.3.2}/demo_walkthrough.py +0 -0
  14. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/agent.py +0 -0
  15. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/client.py +0 -0
  16. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/context.py +0 -0
  17. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/credentials.py +0 -0
  18. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/decorators.py +0 -0
  19. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/errors.py +0 -0
  20. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/graph.py +0 -0
  21. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/integrations/__init__.py +0 -0
  22. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/integrations/anthropic.py +0 -0
  23. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/integrations/openai.py +0 -0
  24. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/model_catalog.py +0 -0
  25. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/models.py +0 -0
  26. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/py.typed +0 -0
  27. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/session.py +0 -0
  28. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/tools.py +0 -0
  29. {usegradient-1.3.1 → usegradient-1.3.2}/gradient_sdk/tracing.py +0 -0
  30. {usegradient-1.3.1 → usegradient-1.3.2}/tests/test_client.py +0 -0
  31. {usegradient-1.3.1 → usegradient-1.3.2}/tests/test_graph.py +0 -0
  32. {usegradient-1.3.1 → usegradient-1.3.2}/tests/test_integrations.py +0 -0
  33. {usegradient-1.3.1 → usegradient-1.3.2}/tests/test_tracing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usegradient
3
- Version: 1.3.1
3
+ Version: 1.3.2
4
4
  Summary: Python SDK for the Gradient agent runtime and observability platform
5
5
  Project-URL: Homepage, https://usegradient.dev
6
6
  Project-URL: Documentation, https://github.com/use-gradient/gradient/tree/main/sdk/python
@@ -98,7 +98,6 @@ if __name__ == "__main__":
98
98
  project=os.getenv("GRADIENT_PROJECT", "my-first-agent"),
99
99
  environment=Environment.python(
100
100
  python="3.12",
101
- packages=["usegradient", "anthropic"],
102
101
  env={"ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"]},
103
102
  ),
104
103
  )
@@ -71,7 +71,6 @@ if __name__ == "__main__":
71
71
  project=os.getenv("GRADIENT_PROJECT", "my-first-agent"),
72
72
  environment=Environment.python(
73
73
  python="3.12",
74
- packages=["usegradient", "anthropic"],
75
74
  env={"ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"]},
76
75
  ),
77
76
  )
@@ -34,7 +34,7 @@ if __name__ == "__main__":
34
34
  project=os.getenv("GRADIENT_PROJECT", "my-first-agent"),
35
35
  environment=Environment.python(
36
36
  python="3.12",
37
- packages=["usegradient", "anthropic"],
37
+ # usegradient + anthropic are inferred from imports / Model.CLAUDE_*.
38
38
  env={"ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"]},
39
39
  ),
40
40
  )
@@ -10,6 +10,7 @@ from .credentials import (
10
10
  save_credentials,
11
11
  )
12
12
  from .decorators import agent, chain, embedding, llm, retriever, task, tool, trace, trace_context, workflow
13
+ from .dependencies import infer_python_packages
13
14
  from .errors import GradientAPIError, GradientAuthError, GradientError
14
15
  from .graph import END, START, StateGraph
15
16
  from .integrations import generate, run_agent_loop, wrap_anthropic, wrap_openai
@@ -103,6 +104,7 @@ __all__ = [
103
104
  "generate",
104
105
  "get_tracer",
105
106
  "infer_parameters",
107
+ "infer_python_packages",
106
108
  "init",
107
109
  "llm",
108
110
  "load_credentials",
@@ -0,0 +1,217 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ # Top-level import name -> PyPI distribution name.
9
+ # Unlisted third-party imports default to the import name itself.
10
+ IMPORT_TO_PACKAGE: dict[str, str | None] = {
11
+ "dotenv": "python-dotenv",
12
+ "gradient_sdk": "usegradient",
13
+ "PIL": "Pillow",
14
+ "cv2": "opencv-python",
15
+ "sklearn": "scikit-learn",
16
+ "yaml": "PyYAML",
17
+ "bs4": "beautifulsoup4",
18
+ "attr": "attrs",
19
+ "serial": "pyserial",
20
+ "jwt": "PyJWT",
21
+ "Crypto": "pycryptodome",
22
+ "OpenSSL": "pyOpenSSL",
23
+ "dateutil": "python-dateutil",
24
+ "skimage": "scikit-image",
25
+ "gi": None, # system/bindings package; not pip-installable in the usual way
26
+ "cairo": None,
27
+ }
28
+
29
+
30
+ def infer_python_packages(
31
+ entrypoint: str | Path,
32
+ *,
33
+ source_root: str | Path | None = None,
34
+ explicit: Iterable[str] | None = None,
35
+ ) -> list[str]:
36
+ """Infer pip packages from imports in ``entrypoint`` and local modules it uses.
37
+
38
+ Stdlib and local project modules are skipped. Known import renames
39
+ (for example ``dotenv`` -> ``python-dotenv``) are applied. Explicit packages
40
+ are always kept and appear first.
41
+ """
42
+
43
+ entry = Path(entrypoint).resolve()
44
+ root = Path(source_root or entry.parent).resolve()
45
+ explicit_packages = _normalize_packages(explicit or [])
46
+ if not entry.exists():
47
+ return explicit_packages
48
+
49
+ local_names = _local_module_names(root)
50
+ stdlib = set(sys.stdlib_module_names)
51
+ top_level_imports: set[str] = set()
52
+ provider_packages: set[str] = set()
53
+ queued: list[Path] = [entry]
54
+ seen_files: set[Path] = set()
55
+
56
+ while queued:
57
+ path = queued.pop()
58
+ if path in seen_files or not path.is_file():
59
+ continue
60
+ seen_files.add(path)
61
+ provider_packages.update(_infer_provider_packages(path))
62
+ for module_name, level in _iter_import_modules(path):
63
+ if level > 0:
64
+ local_path = _resolve_relative_import(path, root, module_name, level)
65
+ if local_path is not None:
66
+ queued.append(local_path)
67
+ continue
68
+ if not module_name:
69
+ continue
70
+ top = module_name.split(".", 1)[0]
71
+ top_level_imports.add(top)
72
+ # Known third-party/SDK imports should not be scanned as local source,
73
+ # even if a same-named directory exists in the source root (monorepo).
74
+ if top in IMPORT_TO_PACKAGE:
75
+ continue
76
+ if top in local_names:
77
+ queued.extend(_local_module_files(root, module_name))
78
+
79
+ inferred: list[str] = []
80
+ seen: set[str] = set(explicit_packages)
81
+ for name in sorted(top_level_imports | provider_packages):
82
+ if name in stdlib:
83
+ continue
84
+ if name in IMPORT_TO_PACKAGE:
85
+ package = IMPORT_TO_PACKAGE[name]
86
+ elif name in {"anthropic", "openai"}:
87
+ package = name
88
+ elif name in local_names:
89
+ continue
90
+ else:
91
+ package = name
92
+ if not package or package in seen:
93
+ continue
94
+ seen.add(package)
95
+ inferred.append(package)
96
+
97
+ return [*explicit_packages, *inferred]
98
+
99
+
100
+ def merge_python_packages(
101
+ *,
102
+ entrypoint: str | Path | None,
103
+ source_root: str | Path,
104
+ explicit: Iterable[str] | None = None,
105
+ ) -> list[str]:
106
+ if entrypoint:
107
+ return infer_python_packages(
108
+ entrypoint,
109
+ source_root=source_root,
110
+ explicit=explicit,
111
+ )
112
+ return _normalize_packages(explicit or [])
113
+
114
+
115
+ def _normalize_packages(packages: Iterable[str]) -> list[str]:
116
+ result: list[str] = []
117
+ seen: set[str] = set()
118
+ for item in packages:
119
+ name = str(item).strip()
120
+ if not name or name in seen:
121
+ continue
122
+ seen.add(name)
123
+ result.append(name)
124
+ return result
125
+
126
+
127
+ def _local_module_names(root: Path) -> set[str]:
128
+ names: set[str] = set()
129
+ if not root.is_dir():
130
+ return names
131
+ for child in root.iterdir():
132
+ if child.name.startswith("."):
133
+ continue
134
+ if child.is_file() and child.suffix == ".py" and child.name != "__init__.py":
135
+ names.add(child.stem)
136
+ elif child.is_dir() and not child.name.startswith("__"):
137
+ if (child / "__init__.py").exists() or any(child.rglob("*.py")):
138
+ names.add(child.name)
139
+ return names
140
+
141
+
142
+ def _local_module_files(root: Path, module_name: str) -> list[Path]:
143
+ parts = module_name.split(".")
144
+ base = root.joinpath(*parts)
145
+ candidates = [
146
+ Path(f"{base}.py"),
147
+ base / "__init__.py",
148
+ ]
149
+ return [path.resolve() for path in candidates if path.is_file()]
150
+
151
+
152
+ def _resolve_relative_import(
153
+ current_file: Path,
154
+ root: Path,
155
+ module_name: str | None,
156
+ level: int,
157
+ ) -> Path | None:
158
+ current_dir = current_file.parent
159
+ # level=1 means current package; climb level-1 parents for package context
160
+ package_dir = current_dir
161
+ for _ in range(max(level - 1, 0)):
162
+ package_dir = package_dir.parent
163
+ if module_name:
164
+ target = package_dir.joinpath(*module_name.split("."))
165
+ else:
166
+ target = package_dir
167
+ for candidate in (Path(f"{target}.py"), target / "__init__.py"):
168
+ try:
169
+ resolved = candidate.resolve()
170
+ resolved.relative_to(root)
171
+ except Exception:
172
+ continue
173
+ if resolved.is_file():
174
+ return resolved
175
+ return None
176
+
177
+
178
+ def _iter_import_modules(path: Path) -> list[tuple[str | None, int]]:
179
+ try:
180
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
181
+ except (OSError, SyntaxError):
182
+ return []
183
+
184
+ found: list[tuple[str | None, int]] = []
185
+ for node in ast.walk(tree):
186
+ if isinstance(node, ast.Import):
187
+ for alias in node.names:
188
+ found.append((alias.name, 0))
189
+ elif isinstance(node, ast.ImportFrom):
190
+ found.append((node.module, int(node.level or 0)))
191
+ return found
192
+
193
+
194
+ def _infer_provider_packages(path: Path) -> set[str]:
195
+ """Detect provider SDKs referenced via model ids/enums without direct imports."""
196
+
197
+ try:
198
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
199
+ except (OSError, SyntaxError):
200
+ return set()
201
+
202
+ found: set[str] = set()
203
+ for node in ast.walk(tree):
204
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
205
+ if node.value.startswith("anthropic:"):
206
+ found.add("anthropic")
207
+ elif node.value.startswith("openai:"):
208
+ found.add("openai")
209
+ elif isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
210
+ if node.value.id != "Model":
211
+ continue
212
+ attr = node.attr.upper()
213
+ if attr.startswith("CLAUDE"):
214
+ found.add("anthropic")
215
+ elif attr.startswith("GPT"):
216
+ found.add("openai")
217
+ return found
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- from typing import Any, Literal
4
+ from typing import Any, Literal, Mapping
5
5
 
6
6
  from ..tools import Tool, execute_tool_calls
7
7
  from ..tracing import get_tracer
@@ -218,16 +218,15 @@ def _generate_anthropic(
218
218
  from anthropic import Anthropic
219
219
 
220
220
  client = Anthropic()
221
- if isinstance(messages, str):
222
- anthropic_messages = [{"role": "user", "content": messages}]
223
- else:
224
- anthropic_messages = messages
221
+ system, anthropic_messages = _anthropic_messages(messages)
225
222
  payload: dict[str, Any] = {
226
223
  "model": model,
227
224
  "max_tokens": kwargs.pop("max_tokens", 1024),
228
225
  "messages": anthropic_messages,
229
226
  **kwargs,
230
227
  }
228
+ if system:
229
+ payload["system"] = system
231
230
  if tools:
232
231
  payload["tools"] = [tool.to_anthropic() for tool in tools]
233
232
  response = client.messages.create(**payload)
@@ -257,6 +256,98 @@ def _generate_anthropic(
257
256
  }
258
257
 
259
258
 
259
+ def _anthropic_messages(
260
+ messages: list[dict[str, Any]] | str,
261
+ ) -> tuple[str | None, list[dict[str, Any]]]:
262
+ """Convert chat-style messages into Anthropic Messages API shape.
263
+
264
+ Anthropic rejects role=system entries in ``messages`` and expects the
265
+ initial system prompt in the top-level ``system`` parameter instead.
266
+ """
267
+
268
+ if isinstance(messages, str):
269
+ return None, [{"role": "user", "content": messages}]
270
+
271
+ system_parts: list[str] = []
272
+ converted: list[dict[str, Any]] = []
273
+ for message in messages:
274
+ role = str(message.get("role") or "user")
275
+ content = message.get("content", "")
276
+ if role == "system":
277
+ text = _message_text(content)
278
+ if text:
279
+ system_parts.append(text)
280
+ continue
281
+ if role == "tool":
282
+ converted.append(
283
+ {
284
+ "role": "user",
285
+ "content": [
286
+ {
287
+ "type": "tool_result",
288
+ "tool_use_id": message.get("tool_call_id") or message.get("id"),
289
+ "content": _message_text(content),
290
+ }
291
+ ],
292
+ }
293
+ )
294
+ continue
295
+ if role == "assistant" and message.get("tool_calls"):
296
+ blocks: list[dict[str, Any]] = []
297
+ text = _message_text(content)
298
+ if text:
299
+ blocks.append({"type": "text", "text": text})
300
+ for call in message.get("tool_calls") or []:
301
+ arguments = call.get("arguments", call.get("input", {}))
302
+ if isinstance(arguments, str):
303
+ try:
304
+ arguments = json.loads(arguments)
305
+ except json.JSONDecodeError:
306
+ arguments = {"raw": arguments}
307
+ blocks.append(
308
+ {
309
+ "type": "tool_use",
310
+ "id": call.get("id"),
311
+ "name": call.get("name", ""),
312
+ "input": arguments if isinstance(arguments, dict) else {"value": arguments},
313
+ }
314
+ )
315
+ converted.append({"role": "assistant", "content": blocks or text})
316
+ continue
317
+ if role not in {"user", "assistant"}:
318
+ role = "user"
319
+ converted.append({"role": role, "content": content})
320
+
321
+ if not converted:
322
+ converted = [{"role": "user", "content": ""}]
323
+ return ("\n\n".join(system_parts) if system_parts else None), converted
324
+
325
+
326
+ def _message_text(content: Any) -> str:
327
+ if content is None:
328
+ return ""
329
+ if isinstance(content, str):
330
+ return content
331
+ if isinstance(content, list):
332
+ parts: list[str] = []
333
+ for item in content:
334
+ if isinstance(item, Mapping):
335
+ text = item.get("text")
336
+ if text:
337
+ parts.append(str(text))
338
+ else:
339
+ parts.append(json.dumps(item, default=str))
340
+ else:
341
+ parts.append(str(item))
342
+ return "".join(parts)
343
+ if isinstance(content, Mapping):
344
+ text = content.get("text")
345
+ if text is not None:
346
+ return str(text)
347
+ return json.dumps(content, default=str)
348
+ return str(content)
349
+
350
+
260
351
  def _get(value: Any, key: str, default: Any = None) -> Any:
261
352
  if isinstance(value, dict):
262
353
  return value.get(key, default)
@@ -9,6 +9,7 @@ import io
9
9
  import json
10
10
  import os
11
11
  import posixpath
12
+ import shlex
12
13
  import sys
13
14
  import tarfile
14
15
  import time
@@ -17,6 +18,7 @@ from pathlib import Path
17
18
  from typing import Any, Callable, Iterable, Mapping
18
19
 
19
20
  from .client import GradientClient
21
+ from .dependencies import merge_python_packages
20
22
  from .errors import GradientAPIError
21
23
 
22
24
  DEFAULT_EXCLUDES = [
@@ -677,10 +679,19 @@ class Gradient:
677
679
  source.duration_ms,
678
680
  )
679
681
 
682
+ packages = self._python_packages_for_source(source)
683
+ environment_spec = self._environment_spec(packages=packages)
684
+ if packages and self.environment.runtime == "python":
685
+ self._stage(
686
+ "dependencies",
687
+ "ready",
688
+ f"Resolved packages: {', '.join(packages)}",
689
+ )
690
+
680
691
  t0 = time.perf_counter()
681
692
  resolved = self.client.resolve_environment(
682
693
  project=self.project,
683
- environment=self.environment.to_spec(),
694
+ environment=environment_spec,
684
695
  source=source.manifest,
685
696
  )
686
697
  timings["resolve_environment_ms"] = _elapsed_ms(t0)
@@ -698,7 +709,11 @@ class Gradient:
698
709
  t0 = time.perf_counter()
699
710
  traced = self.client.create_traced_machine(
700
711
  template,
701
- machine_template=_with_source_bundle(template, source),
712
+ machine_template=_with_source_bundle(
713
+ template,
714
+ source,
715
+ extra_env=self.environment.env,
716
+ ),
702
717
  trace_mode=trace_policy.trace_mode,
703
718
  capture_policy=trace_policy.capture_policy(),
704
719
  metadata={
@@ -709,6 +724,7 @@ class Gradient:
709
724
  "target": source.target_ref,
710
725
  "trace_preset": trace_policy.preset,
711
726
  "sdk_surface": "Gradient.run",
727
+ "packages": packages,
712
728
  },
713
729
  wait=True,
714
730
  wait_timeout_seconds=wait_timeout_seconds,
@@ -724,49 +740,40 @@ class Gradient:
724
740
  timings["upload_source_ms"] = 0
725
741
  self._stage("upload", "ready", "Mounted source bundle when the machine was created")
726
742
 
727
- setup_command = env_version.get("setup_command")
728
- if setup_command and self.environment.install_at_runtime:
729
- t0 = time.perf_counter()
730
- setup = self._exec_machine_with_retry(
731
- machine_id,
732
- command=["sh", "-lc", f"cd /workspace && {setup_command}"],
733
- timeout_seconds=run_timeout_seconds,
734
- trace=False,
735
- stage="dependencies",
736
- )
737
- timings["runtime_setup_ms"] = _elapsed_ms(t0)
738
- self._stage(
739
- "dependencies",
740
- "ready" if setup.exit_code == 0 else "failed",
741
- "Runtime dependency setup completed" if setup.exit_code == 0 else setup.stderr,
742
- timings["runtime_setup_ms"],
743
- )
744
- if setup.exit_code != 0:
745
- raise RuntimeError(setup.stderr or setup.stdout)
746
- else:
747
- self._stage("dependencies", "cached", "Skipped runtime install; using cached environment image", cached=True)
743
+ python_bin = self._prepare_remote_python(
744
+ machine_id=machine_id,
745
+ packages=packages,
746
+ setup_command=env_version.get("setup_command"),
747
+ run_timeout_seconds=run_timeout_seconds,
748
+ timings=timings,
749
+ )
748
750
 
749
751
  payload = base64.b64encode(json.dumps(input).encode("utf-8")).decode("ascii")
750
752
  remote_code = _remote_bootstrap(source.target_ref, payload)
751
753
  t0 = time.perf_counter()
752
754
  result = self._exec_machine_with_retry(
753
755
  machine_id,
754
- command=["python", "-c", remote_code],
756
+ command=[python_bin, "-c", remote_code],
755
757
  timeout_seconds=run_timeout_seconds,
756
758
  trace=trace_policy.trace_exec,
757
759
  watch=list(watch or ["/workspace"]),
758
760
  stage="run",
759
761
  )
760
762
  timings["remote_run_ms"] = _elapsed_ms(t0)
763
+ failure_detail = _compact_output(result.stderr or result.stdout)
761
764
  self._stage(
762
765
  "run",
763
766
  "ready" if result.exit_code == 0 else "failed",
764
- f"Remote agent exited with {result.exit_code}",
767
+ (
768
+ f"Remote agent exited with {result.exit_code}"
769
+ if result.exit_code == 0 or not failure_detail
770
+ else f"Remote agent exited with {result.exit_code}: {failure_detail}"
771
+ ),
765
772
  timings["remote_run_ms"],
766
773
  )
767
774
 
768
775
  timings["total_ms"] = _elapsed_ms(overall)
769
- return RunHandle(
776
+ handle = RunHandle(
770
777
  run_id=run_id,
771
778
  machine_id=machine_id,
772
779
  environment_version_id=str(env_version["id"]),
@@ -781,6 +788,13 @@ class Gradient:
781
788
  client=self.client,
782
789
  input=input,
783
790
  )
791
+ if result.exit_code != 0:
792
+ detail = (result.stderr or result.stdout or "").strip()
793
+ raise RuntimeError(
794
+ detail
795
+ or f"Remote agent exited with {result.exit_code}. Trace: {handle.trace_url}"
796
+ )
797
+ return handle
784
798
  finally:
785
799
  if not keep_machine:
786
800
  t0 = time.perf_counter()
@@ -831,10 +845,13 @@ class Gradient:
831
845
  source.duration_ms,
832
846
  )
833
847
 
848
+ packages = self._python_packages_for_source(source)
849
+ environment_spec = self._environment_spec(packages=packages)
850
+
834
851
  t0 = time.perf_counter()
835
852
  resolved = self.client.resolve_environment(
836
853
  project=self.project,
837
- environment=self.environment.to_spec(),
854
+ environment=environment_spec,
838
855
  source=source.manifest,
839
856
  )
840
857
  timings["resolve_environment_ms"] = _elapsed_ms(t0)
@@ -856,6 +873,7 @@ class Gradient:
856
873
  env_version["machine_template"],
857
874
  source,
858
875
  service_code=_remote_service_bootstrap(source.target_ref),
876
+ extra_env=self.environment.env,
859
877
  ),
860
878
  environment_version_id=str(env_version["id"]),
861
879
  trace_mode=trace_policy.trace_mode,
@@ -896,27 +914,13 @@ class Gradient:
896
914
  timings["upload_source_ms"] = 0
897
915
  self._stage("upload", "ready", "Mounted deployment source bundle at machine creation")
898
916
 
899
- setup_command = env_version.get("setup_command")
900
- if setup_command and self.environment.install_at_runtime:
901
- t0 = time.perf_counter()
902
- setup = self._exec_machine_with_retry(
903
- machine_id,
904
- command=["sh", "-lc", f"cd /workspace && {setup_command}"],
905
- timeout_seconds=run_timeout_seconds,
906
- trace=False,
907
- stage="dependencies",
908
- )
909
- timings["runtime_setup_ms"] = _elapsed_ms(t0)
910
- self._stage(
911
- "dependencies",
912
- "ready" if setup.exit_code == 0 else "failed",
913
- "Runtime dependency setup completed" if setup.exit_code == 0 else setup.stderr,
914
- timings["runtime_setup_ms"],
915
- )
916
- if setup.exit_code != 0:
917
- raise RuntimeError(setup.stderr or setup.stdout)
918
- else:
919
- self._stage("dependencies", "cached", "Skipped runtime install; using cached environment image", cached=True)
917
+ python_bin = self._prepare_remote_python(
918
+ machine_id=machine_id,
919
+ packages=packages,
920
+ setup_command=env_version.get("setup_command"),
921
+ run_timeout_seconds=run_timeout_seconds,
922
+ timings=timings,
923
+ )
920
924
 
921
925
  t0 = time.perf_counter()
922
926
  started = self._exec_machine_with_retry(
@@ -924,7 +928,7 @@ class Gradient:
924
928
  command=[
925
929
  "sh",
926
930
  "-lc",
927
- "cd /workspace && nohup python -u /tmp/gradient-service.py >/tmp/gradient-service.log 2>&1 &",
931
+ f"cd /workspace && nohup {shlex.quote(python_bin)} -u /tmp/gradient-service.py >/tmp/gradient-service.log 2>&1 &",
928
932
  ],
929
933
  timeout_seconds=30,
930
934
  trace=trace_policy.trace_exec,
@@ -1009,6 +1013,89 @@ class Gradient:
1009
1013
  return _InstrumentedRunnable(target)
1010
1014
  return target
1011
1015
 
1016
+ def _python_packages_for_source(self, source: SourceBundle) -> list[str]:
1017
+ if self.environment.runtime != "python":
1018
+ return list(self.environment.packages)
1019
+ entrypoint = (
1020
+ (self.source_root / source.entrypoint).resolve()
1021
+ if source.entrypoint
1022
+ else None
1023
+ )
1024
+ return merge_python_packages(
1025
+ entrypoint=entrypoint,
1026
+ source_root=self.source_root,
1027
+ explicit=self.environment.packages,
1028
+ )
1029
+
1030
+ def _environment_spec(self, *, packages: list[str] | None = None) -> dict[str, Any]:
1031
+ spec = self.environment.to_spec()
1032
+ if packages is not None:
1033
+ spec["packages"] = list(packages)
1034
+ return spec
1035
+
1036
+ def _prepare_remote_python(
1037
+ self,
1038
+ *,
1039
+ machine_id: str,
1040
+ packages: list[str],
1041
+ setup_command: str | None,
1042
+ run_timeout_seconds: int,
1043
+ timings: dict[str, int],
1044
+ ) -> str:
1045
+ if self.environment.runtime == "python" and packages:
1046
+ command = _python_venv_install_command(packages)
1047
+ t0 = time.perf_counter()
1048
+ setup = self._exec_machine_with_retry(
1049
+ machine_id,
1050
+ command=["sh", "-lc", command],
1051
+ timeout_seconds=run_timeout_seconds,
1052
+ trace=False,
1053
+ stage="dependencies",
1054
+ )
1055
+ timings["runtime_setup_ms"] = _elapsed_ms(t0)
1056
+ detail = _compact_output(setup.stderr or setup.stdout)
1057
+ self._stage(
1058
+ "dependencies",
1059
+ "ready" if setup.exit_code == 0 else "failed",
1060
+ (
1061
+ f"Installed {len(packages)} package(s) into remote venv"
1062
+ if setup.exit_code == 0
1063
+ else detail or "Remote dependency install failed"
1064
+ ),
1065
+ timings["runtime_setup_ms"],
1066
+ )
1067
+ if setup.exit_code != 0:
1068
+ raise RuntimeError(detail or setup.stderr or setup.stdout or "Remote dependency install failed")
1069
+ return "/tmp/gradient-venv/bin/python"
1070
+
1071
+ if setup_command and self.environment.install_at_runtime:
1072
+ t0 = time.perf_counter()
1073
+ setup = self._exec_machine_with_retry(
1074
+ machine_id,
1075
+ command=["sh", "-lc", f"cd /workspace && {setup_command}"],
1076
+ timeout_seconds=run_timeout_seconds,
1077
+ trace=False,
1078
+ stage="dependencies",
1079
+ )
1080
+ timings["runtime_setup_ms"] = _elapsed_ms(t0)
1081
+ self._stage(
1082
+ "dependencies",
1083
+ "ready" if setup.exit_code == 0 else "failed",
1084
+ "Runtime dependency setup completed" if setup.exit_code == 0 else setup.stderr,
1085
+ timings["runtime_setup_ms"],
1086
+ )
1087
+ if setup.exit_code != 0:
1088
+ raise RuntimeError(setup.stderr or setup.stdout)
1089
+ return "python"
1090
+
1091
+ self._stage(
1092
+ "dependencies",
1093
+ "cached",
1094
+ "Skipped runtime install; using cached environment image",
1095
+ cached=True,
1096
+ )
1097
+ return "python"
1098
+
1012
1099
  def _exec_machine_with_retry(
1013
1100
  self,
1014
1101
  machine_id: str,
@@ -1133,10 +1220,13 @@ def _with_source_bundle(
1133
1220
  source: SourceBundle,
1134
1221
  *,
1135
1222
  service_code: str | None = None,
1223
+ extra_env: Mapping[str, str] | None = None,
1136
1224
  ) -> dict[str, Any]:
1137
1225
  result = copy.deepcopy(dict(template))
1138
1226
  config = dict(result.get("config") or {})
1139
1227
  env = dict(config.get("env") or {})
1228
+ if extra_env:
1229
+ env.update({key: str(value) for key, value in extra_env.items() if value is not None})
1140
1230
  env["GRADIENT_SOURCE_ARCHIVE_B64"] = source.archive_b64
1141
1231
  init_steps = [
1142
1232
  "rm -rf /workspace",
@@ -1258,7 +1348,14 @@ def _find_global_object_ref(root: Path, target: Any) -> tuple[str, str] | None:
1258
1348
  continue
1259
1349
  for name, value in vars(module).items():
1260
1350
  if value is target and not name.startswith("_"):
1261
- return entrypoint, f"{module.__name__}:{name}"
1351
+ module_name = module.__name__
1352
+ if module_name == "__main__":
1353
+ module_name = (
1354
+ entrypoint[:-3].replace("/", ".")
1355
+ if entrypoint.endswith(".py")
1356
+ else entrypoint.replace("/", ".")
1357
+ )
1358
+ return entrypoint, f"{module_name}:{name}"
1262
1359
  return None
1263
1360
 
1264
1361
 
@@ -1406,6 +1503,22 @@ def _elapsed_ms(started: float) -> int:
1406
1503
  return int((time.perf_counter() - started) * 1000)
1407
1504
 
1408
1505
 
1506
+ def _compact_output(value: str | None, *, limit: int = 500) -> str:
1507
+ text = " ".join(str(value or "").split())
1508
+ if len(text) <= limit:
1509
+ return text
1510
+ return text[: limit - 3] + "..."
1511
+
1512
+
1513
+ def _python_venv_install_command(packages: list[str]) -> str:
1514
+ quoted = " ".join(shlex.quote(package) for package in packages)
1515
+ return (
1516
+ "python -m venv /tmp/gradient-venv && "
1517
+ "/tmp/gradient-venv/bin/pip install --upgrade pip && "
1518
+ f"/tmp/gradient-venv/bin/pip install --no-cache-dir {quoted}"
1519
+ )
1520
+
1521
+
1409
1522
  def _stable_hash(value: Any) -> str:
1410
1523
  return hashlib.sha256(
1411
1524
  json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "usegradient"
7
- version = "1.3.1"
7
+ version = "1.3.2"
8
8
  description = "Python SDK for the Gradient agent runtime and observability platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -138,11 +138,18 @@ class AgentTests(unittest.TestCase):
138
138
  class Client:
139
139
  messages = Messages()
140
140
 
141
- run = Agent(Model.CLAUDE_HAIKU_4_5, client=Client())
141
+ run = Agent(
142
+ Model.CLAUDE_HAIKU_4_5,
143
+ client=Client(),
144
+ instructions="You are a helpful assistant.",
145
+ )
142
146
  result = run.run("hello")
143
147
 
144
148
  self.assertEqual(result.text, "anthropic response")
145
149
  self.assertEqual(calls[0]["model"], "claude-haiku-4-5")
150
+ self.assertEqual(calls[0]["system"], "You are a helpful assistant.")
151
+ self.assertEqual(calls[0]["messages"][0]["role"], "user")
152
+ self.assertTrue(all(message["role"] != "system" for message in calls[0]["messages"]))
146
153
  self.assertIn("messages", calls[0])
147
154
 
148
155
  def test_global_agent_can_be_packaged_as_runtime_target(self):
@@ -0,0 +1,71 @@
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ from gradient_sdk.dependencies import infer_python_packages
6
+
7
+
8
+ class DependencyInferenceTests(unittest.TestCase):
9
+ def test_infers_third_party_imports_and_keeps_explicit_packages(self):
10
+ with tempfile.TemporaryDirectory() as tmp:
11
+ root = Path(tmp)
12
+ entry = root / "agent.py"
13
+ entry.write_text(
14
+ "\n".join(
15
+ [
16
+ "import os",
17
+ "from dotenv import load_dotenv",
18
+ "from gradient_sdk import Agent",
19
+ "import anthropic",
20
+ "from helper import value",
21
+ "",
22
+ "load_dotenv()",
23
+ "value = value",
24
+ ]
25
+ ),
26
+ encoding="utf-8",
27
+ )
28
+ (root / "helper.py").write_text("value = 1\n", encoding="utf-8")
29
+
30
+ packages = infer_python_packages(
31
+ entry,
32
+ source_root=root,
33
+ explicit=["anthropic"],
34
+ )
35
+
36
+ self.assertEqual(packages, ["anthropic", "python-dotenv", "usegradient"])
37
+
38
+ def test_infers_provider_from_model_enum(self):
39
+ with tempfile.TemporaryDirectory() as tmp:
40
+ root = Path(tmp)
41
+ entry = root / "agent.py"
42
+ entry.write_text(
43
+ "\n".join(
44
+ [
45
+ "from gradient_sdk import Agent, Model",
46
+ "agent = Agent(Model.CLAUDE_HAIKU_4_5)",
47
+ ]
48
+ ),
49
+ encoding="utf-8",
50
+ )
51
+ packages = infer_python_packages(entry, source_root=root)
52
+
53
+ self.assertEqual(packages, ["anthropic", "usegradient"])
54
+
55
+ def test_follows_local_relative_imports(self):
56
+ with tempfile.TemporaryDirectory() as tmp:
57
+ root = Path(tmp)
58
+ pkg = root / "app"
59
+ pkg.mkdir()
60
+ (pkg / "__init__.py").write_text("", encoding="utf-8")
61
+ (pkg / "tools.py").write_text("import requests\n", encoding="utf-8")
62
+ entry = pkg / "agent.py"
63
+ entry.write_text("from .tools import *\nimport json\n", encoding="utf-8")
64
+
65
+ packages = infer_python_packages(entry, source_root=root)
66
+
67
+ self.assertEqual(packages, ["requests"])
68
+
69
+
70
+ if __name__ == "__main__":
71
+ unittest.main()
@@ -127,7 +127,9 @@ class FakeClient:
127
127
 
128
128
  def exec_machine(self, machine_id, command, **kwargs):
129
129
  self.calls.append(("exec_machine", machine_id, command, kwargs))
130
- if command[:2] == ["python", "-c"]:
130
+ if command[:2] == ["sh", "-lc"] and "gradient-venv" in command[2]:
131
+ return ExecResult(stdout="installed\n")
132
+ if len(command) >= 2 and command[1] == "-c":
131
133
  return ExecResult(stdout='{"ok": true}\n')
132
134
  return ExecResult(stdout="ok\n")
133
135
 
@@ -211,10 +213,19 @@ class RuntimeTests(unittest.TestCase):
211
213
  run_exec = next(
212
214
  call
213
215
  for call in client.calls
214
- if call[0] == "exec_machine" and call[2][:2] == ["python", "-c"]
216
+ if call[0] == "exec_machine" and len(call[2]) >= 2 and call[2][1] == "-c"
215
217
  )
216
218
  self.assertTrue(run_exec[3]["trace"])
217
219
  self.assertEqual(run_exec[3]["watch"], ["/workspace"])
220
+ self.assertTrue(run_exec[2][0].endswith("python"))
221
+ resolve_call = next(call for call in client.calls if call[0] == "resolve_environment")
222
+ self.assertIn("usegradient", resolve_call[1]["environment"]["packages"])
223
+ setup_calls = [
224
+ call
225
+ for call in client.calls
226
+ if call[0] == "exec_machine" and call[2][:2] == ["sh", "-lc"] and "gradient-venv" in call[2][2]
227
+ ]
228
+ self.assertEqual(len(setup_calls), 1)
218
229
 
219
230
  def test_gradient_build_returns_v1_handle(self):
220
231
  client = FakeClient()
@@ -250,7 +261,7 @@ class RuntimeTests(unittest.TestCase):
250
261
  run_exec = next(
251
262
  call
252
263
  for call in client.calls
253
- if call[0] == "exec_machine" and call[2][:2] == ["python", "-c"]
264
+ if call[0] == "exec_machine" and len(call[2]) >= 2 and call[2][1] == "-c"
254
265
  )
255
266
  self.assertFalse(run_exec[3]["trace"])
256
267
 
@@ -320,7 +331,9 @@ class RuntimeTests(unittest.TestCase):
320
331
  "Fly API 412 POST ... exec request failed: EOF",
321
332
  response_body='{"error":"failed_precondition: exec request failed: EOF"}',
322
333
  )
323
- if command[:2] == ["python", "-c"]:
334
+ if command[:2] == ["sh", "-lc"] and "gradient-venv" in command[2]:
335
+ return ExecResult(stdout="installed\n")
336
+ if len(command) >= 2 and command[1] == "-c":
324
337
  return ExecResult(stdout='{"ok": true}\n')
325
338
  return ExecResult(stdout="ok\n")
326
339
 
@@ -355,7 +368,9 @@ class RuntimeTests(unittest.TestCase):
355
368
  "Fly API 412 POST ... machine not running",
356
369
  response_body='{"error":"failed_precondition: machine not running"}',
357
370
  )
358
- if command[:2] == ["python", "-c"]:
371
+ if command[:2] == ["sh", "-lc"] and "gradient-venv" in command[2]:
372
+ return ExecResult(stdout="installed\n")
373
+ if len(command) >= 2 and command[1] == "-c":
359
374
  return ExecResult(stdout='{"ok": true}\n')
360
375
  return ExecResult(stdout="ok\n")
361
376
 
@@ -391,10 +406,13 @@ class RuntimeTests(unittest.TestCase):
391
406
  serve_exec = next(
392
407
  call
393
408
  for call in client.calls
394
- if call[0] == "exec_machine" and call[2][:2] == ["sh", "-lc"]
409
+ if call[0] == "exec_machine"
410
+ and call[2][:2] == ["sh", "-lc"]
411
+ and "gradient-service.py" in call[2][2]
395
412
  )
396
413
  self.assertTrue(serve_exec[3]["trace"])
397
414
  self.assertEqual(serve_exec[3]["watch"], ["/workspace"])
415
+ self.assertIn("/tmp/gradient-venv/bin/python", serve_exec[2][2])
398
416
 
399
417
  stopped = deployment.stop()
400
418
  self.assertEqual(stopped.status, "stopped")
File without changes