gravity-cli 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.
- gravity_cli/__init__.py +11 -0
- gravity_cli/__main__.py +4 -0
- gravity_cli/_runner_script.py +195 -0
- gravity_cli/api.py +88 -0
- gravity_cli/cli.py +124 -0
- gravity_cli/commands/__init__.py +0 -0
- gravity_cli/commands/adapt.py +155 -0
- gravity_cli/commands/build.py +83 -0
- gravity_cli/commands/init.py +78 -0
- gravity_cli/commands/login.py +58 -0
- gravity_cli/commands/push.py +98 -0
- gravity_cli/commands/run.py +281 -0
- gravity_cli/commands/runs.py +49 -0
- gravity_cli/commands/schedule.py +74 -0
- gravity_cli/commands/tools.py +27 -0
- gravity_cli/commands/validate.py +195 -0
- gravity_cli/config.py +91 -0
- gravity_cli/manifest.py +19 -0
- gravity_cli/project.py +42 -0
- gravity_cli/templates/chat/.gitignore +6 -0
- gravity_cli/templates/chat/manifest.yaml +28 -0
- gravity_cli/templates/chat/requirements.txt +3 -0
- gravity_cli/templates/chat/src/agent.py +80 -0
- gravity_cli/templates/deepagent/.gitignore +6 -0
- gravity_cli/templates/deepagent/manifest.yaml +28 -0
- gravity_cli/templates/deepagent/requirements.txt +4 -0
- gravity_cli/templates/deepagent/src/agent.py +54 -0
- gravity_cli/templates/minimal/.gitignore +6 -0
- gravity_cli/templates/minimal/manifest.yaml +30 -0
- gravity_cli/templates/minimal/requirements.txt +3 -0
- gravity_cli/templates/minimal/src/agent.py +58 -0
- gravity_cli/templates/structured/.gitignore +6 -0
- gravity_cli/templates/structured/manifest.yaml +33 -0
- gravity_cli/templates/structured/requirements.txt +3 -0
- gravity_cli/templates/structured/src/agent.py +15 -0
- gravity_cli/templates/structured/src/gateway.py +26 -0
- gravity_cli/templates/structured/src/nodes/agent.py +20 -0
- gravity_cli/templates/structured/src/prompts.py +6 -0
- gravity_cli/templates/structured/src/state.py +10 -0
- gravity_cli/timeline.py +254 -0
- gravity_cli-0.1.0.dist-info/METADATA +494 -0
- gravity_cli-0.1.0.dist-info/RECORD +46 -0
- gravity_cli-0.1.0.dist-info/WHEEL +4 -0
- gravity_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gravity_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- gravity_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from gravity_schema import LOCKFILE_PLATFORM
|
|
11
|
+
from gravity_schema.contract import TOOL_NAME_RE as _TOOL_NAME_RE
|
|
12
|
+
from rich import print as rprint
|
|
13
|
+
|
|
14
|
+
from ..manifest import ManifestValidationError, load_manifest, render_issues
|
|
15
|
+
|
|
16
|
+
MANIFEST_FILE = "manifest.yaml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _uv_bin() -> str | None:
|
|
20
|
+
"""uv on PATH, else the binary the `uv` dependency installed."""
|
|
21
|
+
found = shutil.which("uv")
|
|
22
|
+
if found:
|
|
23
|
+
return found
|
|
24
|
+
try:
|
|
25
|
+
from uv import find_uv_bin
|
|
26
|
+
|
|
27
|
+
return find_uv_bin()
|
|
28
|
+
except Exception: # noqa: BLE001 - no uv at all is a warning, not a crash
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
# The sandbox denies egress except the gateway, so these fail in prod. Flagged, not blocked.
|
|
32
|
+
NETWORK_IMPORTS = {"requests", "urllib3", "socket", "aiohttp", "http.client"}
|
|
33
|
+
|
|
34
|
+
# Quoted literals shaped like a tool name, from the schema's own pattern.
|
|
35
|
+
TOOL_NAME_RE = re.compile(r"""['"](""" + _TOOL_NAME_RE.pattern.strip("^$") + r""")['"]""")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_entrypoint_defines_object(entry_path: Path, entry_object: str) -> list[str]:
|
|
39
|
+
"""Static: parse, never import, and look for a module-level definition of the object."""
|
|
40
|
+
problems = []
|
|
41
|
+
if not entry_path.exists():
|
|
42
|
+
return [f"entrypoint file {entry_path} does not exist"]
|
|
43
|
+
try:
|
|
44
|
+
tree = ast.parse(entry_path.read_text(encoding="utf-8"), filename=str(entry_path))
|
|
45
|
+
except SyntaxError as e:
|
|
46
|
+
return [f"{entry_path} has a syntax error: {e}"]
|
|
47
|
+
|
|
48
|
+
defined_names = set()
|
|
49
|
+
for node in tree.body:
|
|
50
|
+
if isinstance(node, ast.Assign):
|
|
51
|
+
for target in node.targets:
|
|
52
|
+
if isinstance(target, ast.Name):
|
|
53
|
+
defined_names.add(target.id)
|
|
54
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
55
|
+
defined_names.add(node.name)
|
|
56
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
57
|
+
defined_names.add(node.target.id)
|
|
58
|
+
|
|
59
|
+
if entry_object not in defined_names:
|
|
60
|
+
problems.append(
|
|
61
|
+
f"{entry_path} has no module-level `{entry_object}` — "
|
|
62
|
+
f"check runtime.entrypoint in {MANIFEST_FILE}"
|
|
63
|
+
)
|
|
64
|
+
return problems
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _check_adapters(project_dir: Path, manifest) -> list[str]:
|
|
68
|
+
"""Declared adapters have files, and adapter files are declared (else they'd run ungranted)."""
|
|
69
|
+
problems = []
|
|
70
|
+
for slot, spec in manifest.slots.items():
|
|
71
|
+
folder = project_dir / "src" / "adapters" / slot
|
|
72
|
+
on_disk = {f.stem for f in folder.glob("*.py") if f.stem != "__init__"}
|
|
73
|
+
for name in sorted(set(spec.adapters) - on_disk):
|
|
74
|
+
problems.append(f"slots.{slot}.adapters.{name}: no file src/adapters/{slot}/{name}.py")
|
|
75
|
+
for name in sorted(on_disk - set(spec.adapters)):
|
|
76
|
+
problems.append(
|
|
77
|
+
f"src/adapters/{slot}/{name}.py is not declared — add `{name}: [<tools>]` "
|
|
78
|
+
f"under slots.{slot}.adapters in {MANIFEST_FILE} (that grants its tools)"
|
|
79
|
+
)
|
|
80
|
+
return problems
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _scan_drift(project_dir: Path, declared_tools: list[str]) -> list[str]:
|
|
84
|
+
warnings = []
|
|
85
|
+
src_files = list((project_dir).rglob("*.py"))
|
|
86
|
+
imported_network_libs: set[str] = set()
|
|
87
|
+
referenced_tool_names: set[str] = set()
|
|
88
|
+
|
|
89
|
+
for f in src_files:
|
|
90
|
+
# Path parts, not a substring: Windows paths use backslashes.
|
|
91
|
+
if {".venv", "venv", "tests", "__pycache__"} & set(f.parts):
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
text = f.read_text(encoding="utf-8")
|
|
95
|
+
except (UnicodeDecodeError, OSError):
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
tree = ast.parse(text, filename=str(f))
|
|
99
|
+
for node in ast.walk(tree):
|
|
100
|
+
if isinstance(node, ast.Import):
|
|
101
|
+
for alias in node.names:
|
|
102
|
+
if alias.name in NETWORK_IMPORTS:
|
|
103
|
+
imported_network_libs.add(alias.name)
|
|
104
|
+
elif isinstance(node, ast.ImportFrom) and node.module in NETWORK_IMPORTS:
|
|
105
|
+
imported_network_libs.add(node.module)
|
|
106
|
+
except SyntaxError:
|
|
107
|
+
continue
|
|
108
|
+
referenced_tool_names.update(TOOL_NAME_RE.findall(text))
|
|
109
|
+
|
|
110
|
+
if imported_network_libs and not declared_tools:
|
|
111
|
+
warnings.append(
|
|
112
|
+
f"imports {', '.join(sorted(imported_network_libs))} but permissions.tools is "
|
|
113
|
+
"empty — the sandbox denies all egress except the gateway, so this will 403/timeout "
|
|
114
|
+
"in prod. add the tool(s) you need under permissions.tools, or drop the import."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
undeclared = sorted(referenced_tool_names - set(declared_tools))
|
|
118
|
+
# Cheap heuristic with false positives: a warning, never a failure.
|
|
119
|
+
if undeclared:
|
|
120
|
+
warnings.append(
|
|
121
|
+
f"found tool-name-shaped string(s) not in permissions.tools: {', '.join(undeclared)} "
|
|
122
|
+
"— if these are real tool calls, add them under permissions.tools and re-run."
|
|
123
|
+
)
|
|
124
|
+
return warnings
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def validate_command(
|
|
128
|
+
path: Path = typer.Argument(Path("."), help="Agent folder"),
|
|
129
|
+
):
|
|
130
|
+
"""Local sanity check: manifest, entrypoint, lockfile, adapters, drift scan."""
|
|
131
|
+
project_dir = path.resolve()
|
|
132
|
+
manifest_path = project_dir / MANIFEST_FILE
|
|
133
|
+
errors: list[str] = []
|
|
134
|
+
warnings: list[str] = []
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
manifest = load_manifest(manifest_path)
|
|
138
|
+
except ManifestValidationError as e:
|
|
139
|
+
render_issues(e)
|
|
140
|
+
raise typer.Exit(1) from e
|
|
141
|
+
|
|
142
|
+
entry_file, entry_object = manifest.entrypoint_parts()
|
|
143
|
+
errors += _check_entrypoint_defines_object(project_dir / entry_file, entry_object)
|
|
144
|
+
|
|
145
|
+
lockfile_path = project_dir / manifest.dependencies.lockfile
|
|
146
|
+
requirements_path = project_dir / "requirements.txt"
|
|
147
|
+
if not requirements_path.is_file():
|
|
148
|
+
errors.append(
|
|
149
|
+
"no requirements.txt — the deploy installs from the lock compiled from it, "
|
|
150
|
+
"so it must exist (empty is fine)."
|
|
151
|
+
)
|
|
152
|
+
elif uv := _uv_bin():
|
|
153
|
+
# Resolve for the platform image, not this laptop.
|
|
154
|
+
result = subprocess.run(
|
|
155
|
+
[
|
|
156
|
+
uv, "pip", "compile", "--quiet",
|
|
157
|
+
"--python-platform", LOCKFILE_PLATFORM,
|
|
158
|
+
"--python-version", manifest.runtime.python,
|
|
159
|
+
"--only-binary", ":all:",
|
|
160
|
+
# Relative: uv writes this command into the lock, so an absolute path
|
|
161
|
+
# would leak the home dir and change the zip's hash per machine.
|
|
162
|
+
requirements_path.name, "-o", manifest.dependencies.lockfile,
|
|
163
|
+
],
|
|
164
|
+
cwd=project_dir,
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
check=False,
|
|
168
|
+
)
|
|
169
|
+
if result.returncode != 0:
|
|
170
|
+
errors.append(
|
|
171
|
+
f"`uv pip compile` failed for {LOCKFILE_PLATFORM} / python "
|
|
172
|
+
f"{manifest.runtime.python}:\n{result.stderr.strip()}\n"
|
|
173
|
+
" → every dependency needs a wheel for the platform image; a "
|
|
174
|
+
"source-only package cannot be built there."
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
rprint(
|
|
178
|
+
f"[green]✓[/green] {lockfile_path.name} compiled "
|
|
179
|
+
f"[dim]({LOCKFILE_PLATFORM}, py{manifest.runtime.python})[/dim]"
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
warnings.append("`uv` not found — skipping lockfile compile. install: https://docs.astral.sh/uv/")
|
|
183
|
+
|
|
184
|
+
errors += _check_adapters(project_dir, manifest)
|
|
185
|
+
warnings += _scan_drift(project_dir, sorted(manifest.declared_tools))
|
|
186
|
+
|
|
187
|
+
for w in warnings:
|
|
188
|
+
rprint(f"[yellow]⚠[/yellow] {w}")
|
|
189
|
+
|
|
190
|
+
if errors:
|
|
191
|
+
for e in errors:
|
|
192
|
+
rprint(f"[red]✖[/red] {e}")
|
|
193
|
+
raise typer.Exit(1)
|
|
194
|
+
|
|
195
|
+
rprint(f"[green]✓ {manifest.name}@{manifest.version} is valid[/green]")
|
gravity_cli/config.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""~/.gravity: config.json (the API URL), .env (loaded on every command), and the
|
|
2
|
+
session, kept in the OS keychain with a 0600 file as the headless fallback."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import keyring
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
|
|
16
|
+
KEYRING_SERVICE = "gravity-cli"
|
|
17
|
+
|
|
18
|
+
# GRAVITY_CONFIG_DIR: tests and the platform demo point the CLI at a scratch directory.
|
|
19
|
+
CONFIG_DIR = Path(os.environ.get("GRAVITY_CONFIG_DIR", Path.home() / ".gravity"))
|
|
20
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
21
|
+
SESSION_FALLBACK_FILE = CONFIG_DIR / "session.json"
|
|
22
|
+
ENV_FILE = CONFIG_DIR / ".env"
|
|
23
|
+
|
|
24
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
load_dotenv(ENV_FILE) # no-op if the file doesn't exist yet
|
|
26
|
+
|
|
27
|
+
# The local control plane; the published CLI needs the production URL.
|
|
28
|
+
DEFAULT_API_URL = "http://localhost:3010"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_config() -> dict:
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
34
|
+
except (json.JSONDecodeError, OSError):
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def api_url() -> str:
|
|
39
|
+
url = os.environ.get("GRAVITY_API_URL") or _read_config().get("api_url") or DEFAULT_API_URL
|
|
40
|
+
return str(url).rstrip("/")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def set_api_url(url: str) -> None:
|
|
44
|
+
data = _read_config() | {"api_url": url.rstrip("/")}
|
|
45
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def first_run() -> bool:
|
|
49
|
+
"""True only the first time gravity runs on this machine."""
|
|
50
|
+
data = _read_config()
|
|
51
|
+
if data.get("welcomed"):
|
|
52
|
+
return False
|
|
53
|
+
CONFIG_FILE.write_text(json.dumps(data | {"welcomed": True}, indent=2), encoding="utf-8")
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Session:
|
|
59
|
+
access_token: str
|
|
60
|
+
refresh_token: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Keychain calls fail on headless/CI boxes with no backend; the 0600 file covers those.
|
|
64
|
+
def save_session(session: Session) -> None:
|
|
65
|
+
payload = json.dumps(asdict(session))
|
|
66
|
+
with contextlib.suppress(Exception):
|
|
67
|
+
keyring.set_password(KEYRING_SERVICE, "session", payload)
|
|
68
|
+
return
|
|
69
|
+
SESSION_FALLBACK_FILE.write_text(payload, encoding="utf-8")
|
|
70
|
+
SESSION_FALLBACK_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_session() -> Session | None:
|
|
74
|
+
payload = None
|
|
75
|
+
with contextlib.suppress(Exception):
|
|
76
|
+
payload = keyring.get_password(KEYRING_SERVICE, "session")
|
|
77
|
+
if payload is None and SESSION_FALLBACK_FILE.exists():
|
|
78
|
+
payload = SESSION_FALLBACK_FILE.read_text(encoding="utf-8")
|
|
79
|
+
if not payload:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(payload)
|
|
83
|
+
return Session(data["access_token"], data["refresh_token"])
|
|
84
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def clear_session() -> None:
|
|
89
|
+
with contextlib.suppress(Exception):
|
|
90
|
+
keyring.delete_password(KEYRING_SERVICE, "session")
|
|
91
|
+
SESSION_FALLBACK_FILE.unlink(missing_ok=True)
|
gravity_cli/manifest.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Manifest loading (owned by gravity-schema) and its terminal rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from gravity_schema import Manifest, ManifestIssue, ManifestValidationError
|
|
6
|
+
from gravity_schema import load_manifest_file as load_manifest
|
|
7
|
+
from rich import print as rprint
|
|
8
|
+
|
|
9
|
+
__all__ = ["Manifest", "ManifestIssue", "ManifestValidationError", "load_manifest", "render_issues"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_issues(exc: ManifestValidationError) -> None:
|
|
13
|
+
"""Print every issue, so one round fixes them all."""
|
|
14
|
+
for issue in exc.issues:
|
|
15
|
+
where = issue.location or "manifest.yaml"
|
|
16
|
+
rprint(f"[red]✖[/red] [cyan]{where}[/cyan] [dim]{issue.code.value}[/dim]")
|
|
17
|
+
rprint(f" {issue.message}")
|
|
18
|
+
if issue.remediation:
|
|
19
|
+
rprint(f" [yellow]→[/yellow] {issue.remediation}")
|
gravity_cli/project.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Project-directory helpers shared by validate/run/build/push."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Exactly what `gravity build` zips and ships.
|
|
10
|
+
INCLUDE_DIRS = ("src", "tests")
|
|
11
|
+
INCLUDE_FILES = ("manifest.yaml", "requirements.txt", "requirements.lock")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def project_files(project_dir: Path) -> list[Path]:
|
|
15
|
+
"""Every bundled file, sorted, so build and push agree byte-for-byte."""
|
|
16
|
+
files = [project_dir / n for n in INCLUDE_FILES if (project_dir / n).is_file()]
|
|
17
|
+
for dirname in INCLUDE_DIRS:
|
|
18
|
+
base = project_dir / dirname
|
|
19
|
+
if base.is_dir():
|
|
20
|
+
files += [f for f in base.rglob("*") if f.is_file() and "__pycache__" not in f.parts]
|
|
21
|
+
return sorted(files)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def source_digest(project_dir: Path) -> str:
|
|
25
|
+
"""sha256 of every bundled file's path and bytes; push refuses if it changed since build."""
|
|
26
|
+
h = hashlib.sha256()
|
|
27
|
+
for f in project_files(project_dir):
|
|
28
|
+
h.update(f.relative_to(project_dir).as_posix().encode())
|
|
29
|
+
h.update(b"\0")
|
|
30
|
+
h.update(f.read_bytes())
|
|
31
|
+
h.update(b"\0")
|
|
32
|
+
return h.hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_python(project_dir: Path) -> str:
|
|
36
|
+
"""The project's .venv python, else the one running gravity-cli."""
|
|
37
|
+
venv_python = project_dir / ".venv" / (
|
|
38
|
+
"Scripts/python.exe" if sys.platform == "win32" else "bin/python"
|
|
39
|
+
)
|
|
40
|
+
if venv_python.exists():
|
|
41
|
+
return str(venv_python)
|
|
42
|
+
return sys.executable
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
contract: v1
|
|
2
|
+
name: {{name}}
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: "a conversational langgraph agent - keeps the thread, so you can answer its questions and carry on"
|
|
5
|
+
|
|
6
|
+
runtime:
|
|
7
|
+
framework: langgraph
|
|
8
|
+
python: "3.13"
|
|
9
|
+
entrypoint: src/agent.py:graph
|
|
10
|
+
timeout_seconds: 300
|
|
11
|
+
|
|
12
|
+
dependencies:
|
|
13
|
+
lockfile: requirements.lock
|
|
14
|
+
|
|
15
|
+
inputs:
|
|
16
|
+
- name: message
|
|
17
|
+
type: text
|
|
18
|
+
label: "Say something"
|
|
19
|
+
required: true
|
|
20
|
+
|
|
21
|
+
permissions:
|
|
22
|
+
tools:
|
|
23
|
+
- example.echo
|
|
24
|
+
models:
|
|
25
|
+
tier: standard
|
|
26
|
+
|
|
27
|
+
resources:
|
|
28
|
+
memory_mb: 512
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# src/agent.py - the conversational template. State carries `messages`, which the platform
|
|
2
|
+
# fills with the transcript each turn and stores after, so no checkpointer is needed.
|
|
3
|
+
import os
|
|
4
|
+
from typing import Annotated, TypedDict
|
|
5
|
+
|
|
6
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
7
|
+
from langchain_openai import ChatOpenAI
|
|
8
|
+
from langgraph.graph import END, StateGraph
|
|
9
|
+
from langgraph.graph.message import add_messages
|
|
10
|
+
from langgraph.prebuilt import create_react_agent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class State(TypedDict):
|
|
14
|
+
inputs: dict
|
|
15
|
+
# Declaring this key makes the agent conversational.
|
|
16
|
+
messages: Annotated[list, add_messages]
|
|
17
|
+
result: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SYSTEM = (
|
|
21
|
+
"You are a helpful assistant in an ongoing conversation. The user can reply "
|
|
22
|
+
"and correct you, so read the earlier turns before answering. If a request is "
|
|
23
|
+
"ambiguous, ask ONE short clarifying question instead of guessing - you will "
|
|
24
|
+
"get their answer as the next turn."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _fetch_tools():
|
|
29
|
+
"""Declared tools; none if no gateway is set. A configured gateway that fails raises, never an empty list."""
|
|
30
|
+
url = os.environ.get("GRAVITY_GATEWAY_URL")
|
|
31
|
+
if not url:
|
|
32
|
+
return []
|
|
33
|
+
client = MultiServerMCPClient(
|
|
34
|
+
{
|
|
35
|
+
"gravity": {
|
|
36
|
+
"transport": "streamable_http",
|
|
37
|
+
"url": url,
|
|
38
|
+
"headers": {"Authorization": f"Bearer {os.environ['GRAVITY_RUN_TOKEN']}"},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
return await client.get_tools()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# the object manifest.runtime.entrypoint points at. An async factory the
|
|
46
|
+
# runner calls fresh for every turn - never build the graph at import time.
|
|
47
|
+
async def graph():
|
|
48
|
+
model = ChatOpenAI(
|
|
49
|
+
model="openai/gpt-6-luna",
|
|
50
|
+
base_url=os.environ["GRAVITY_LLM_URL"],
|
|
51
|
+
api_key=os.environ["GRAVITY_RUN_TOKEN"],
|
|
52
|
+
)
|
|
53
|
+
react = create_react_agent(model, await _fetch_tools(), prompt=SYSTEM)
|
|
54
|
+
|
|
55
|
+
async def run_agent(state: State):
|
|
56
|
+
history = list(state.get("messages") or [])
|
|
57
|
+
if not history:
|
|
58
|
+
# Nothing in the thread yet (or a platform that does not thread):
|
|
59
|
+
# fall back to the form input so the agent still works standalone.
|
|
60
|
+
history = [{"role": "user", "content": state["inputs"].get("message", "")}]
|
|
61
|
+
|
|
62
|
+
result = await react.ainvoke({"messages": history})
|
|
63
|
+
|
|
64
|
+
# Return only the new turns: add_messages appends, so the full history would duplicate.
|
|
65
|
+
fresh = result["messages"][len(history):]
|
|
66
|
+
|
|
67
|
+
# The last turn with real text and no pending tool call is the answer.
|
|
68
|
+
answer = ""
|
|
69
|
+
for m in reversed(fresh):
|
|
70
|
+
if getattr(m, "content", "") and not getattr(m, "tool_calls", None):
|
|
71
|
+
answer = m.content
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
return {"messages": fresh, "result": answer}
|
|
75
|
+
|
|
76
|
+
workflow = StateGraph(State)
|
|
77
|
+
workflow.add_node("agent", run_agent)
|
|
78
|
+
workflow.set_entry_point("agent")
|
|
79
|
+
workflow.add_edge("agent", END)
|
|
80
|
+
return workflow.compile()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
contract: v1
|
|
2
|
+
name: {{name}}
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: "a deepagents-harness agent, scaffolded by gravity init"
|
|
5
|
+
|
|
6
|
+
runtime:
|
|
7
|
+
framework: langgraph # deepagents compiles to a langgraph graph
|
|
8
|
+
python: "3.13"
|
|
9
|
+
entrypoint: src/agent.py:graph
|
|
10
|
+
timeout_seconds: 300
|
|
11
|
+
|
|
12
|
+
dependencies:
|
|
13
|
+
lockfile: requirements.lock
|
|
14
|
+
|
|
15
|
+
inputs:
|
|
16
|
+
- name: query
|
|
17
|
+
type: text
|
|
18
|
+
label: "What should the agent do?"
|
|
19
|
+
required: true
|
|
20
|
+
|
|
21
|
+
permissions:
|
|
22
|
+
tools:
|
|
23
|
+
- example.echo
|
|
24
|
+
models:
|
|
25
|
+
tier: standard
|
|
26
|
+
|
|
27
|
+
resources:
|
|
28
|
+
memory_mb: 512
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# src/agent.py — the deepagents template. Filesystem access is denied below; flip `mode` if needed.
|
|
2
|
+
import os
|
|
3
|
+
from typing import TypedDict
|
|
4
|
+
|
|
5
|
+
from deepagents import FilesystemPermission, create_deep_agent
|
|
6
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
7
|
+
from langchain_openai import ChatOpenAI
|
|
8
|
+
from langgraph.graph import END, StateGraph
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# A one-node wrapper: deepagents' state has no `inputs` or `result`.
|
|
12
|
+
class State(TypedDict):
|
|
13
|
+
inputs: dict
|
|
14
|
+
result: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ← the object manifest.runtime.entrypoint points at. An async factory the
|
|
18
|
+
# runner calls fresh for every run — never build the graph at import time.
|
|
19
|
+
async def graph():
|
|
20
|
+
token = os.environ["GRAVITY_RUN_TOKEN"]
|
|
21
|
+
# The run token is the api key; the llm gateway swaps it for the real one.
|
|
22
|
+
model = ChatOpenAI(
|
|
23
|
+
model="openai/gpt-6-luna",
|
|
24
|
+
base_url=os.environ["GRAVITY_LLM_URL"],
|
|
25
|
+
api_key=token,
|
|
26
|
+
)
|
|
27
|
+
client = MultiServerMCPClient(
|
|
28
|
+
{
|
|
29
|
+
"gravity": {
|
|
30
|
+
"transport": "streamable_http",
|
|
31
|
+
"url": os.environ["GRAVITY_GATEWAY_URL"],
|
|
32
|
+
"headers": {"Authorization": f"Bearer {token}"},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
deep_agent = create_deep_agent(
|
|
37
|
+
model=model,
|
|
38
|
+
tools=await client.get_tools(),
|
|
39
|
+
system_prompt="you help with whatever the user's query asks, using the tools available to you.",
|
|
40
|
+
permissions=[
|
|
41
|
+
FilesystemPermission(operations=["read", "write"], paths=["/**"], mode="deny"),
|
|
42
|
+
],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
async def run_agent(state: State):
|
|
46
|
+
query = state["inputs"].get("query", "")
|
|
47
|
+
result = await deep_agent.ainvoke({"messages": [{"role": "user", "content": query}]})
|
|
48
|
+
return {"result": result["messages"][-1].content}
|
|
49
|
+
|
|
50
|
+
workflow = StateGraph(State)
|
|
51
|
+
workflow.add_node("agent", run_agent)
|
|
52
|
+
workflow.set_entry_point("agent")
|
|
53
|
+
workflow.add_edge("agent", END)
|
|
54
|
+
return workflow.compile()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
contract: v1 # runtime contract version — never remove
|
|
2
|
+
name: {{name}}
|
|
3
|
+
version: 1.0.0 # bump to republish; published versions are frozen
|
|
4
|
+
description: "a minimal langgraph agent, scaffolded by gravity init"
|
|
5
|
+
|
|
6
|
+
runtime:
|
|
7
|
+
framework: langgraph # v1: langgraph only
|
|
8
|
+
python: "3.13"
|
|
9
|
+
entrypoint: src/agent.py:graph # file:object — the graph the runner invokes
|
|
10
|
+
timeout_seconds: 300
|
|
11
|
+
|
|
12
|
+
dependencies:
|
|
13
|
+
lockfile: requirements.lock # generated by `gravity validate` (uv) — exact pins
|
|
14
|
+
|
|
15
|
+
# -> rendered as the consumer's form
|
|
16
|
+
inputs:
|
|
17
|
+
- name: query
|
|
18
|
+
type: text
|
|
19
|
+
label: "What should the agent do?"
|
|
20
|
+
required: true
|
|
21
|
+
|
|
22
|
+
# -> becomes the scoped run token; the gateway enforces per call
|
|
23
|
+
permissions:
|
|
24
|
+
tools:
|
|
25
|
+
- example.echo
|
|
26
|
+
models:
|
|
27
|
+
tier: standard # model cap — no premium-tier burn on this agent
|
|
28
|
+
|
|
29
|
+
resources:
|
|
30
|
+
memory_mb: 512 # a request — the platform clamps, never guarantees
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# src/agent.py — the minimal template. No Gravity import: only three env vars.
|
|
2
|
+
import os
|
|
3
|
+
from typing import TypedDict
|
|
4
|
+
|
|
5
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
6
|
+
from langchain_openai import ChatOpenAI
|
|
7
|
+
from langgraph.graph import END, StateGraph
|
|
8
|
+
from langgraph.prebuilt import create_react_agent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Inputs arrive in state["inputs"]; the final `result` is what the consumer gets.
|
|
12
|
+
# No `messages` key: that would make it a chat agent.
|
|
13
|
+
class State(TypedDict):
|
|
14
|
+
inputs: dict
|
|
15
|
+
result: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ← manifest.runtime.entrypoint. An async factory the runner calls per run; building the
|
|
19
|
+
# graph at import breaks inside the runner's event loop.
|
|
20
|
+
async def graph():
|
|
21
|
+
token = os.environ["GRAVITY_RUN_TOKEN"]
|
|
22
|
+
|
|
23
|
+
# models -> gravity's llm gateway: just a base_url. The run token is the
|
|
24
|
+
# api key; the gateway swaps it for the real one.
|
|
25
|
+
model = ChatOpenAI(
|
|
26
|
+
model="openai/gpt-6-luna",
|
|
27
|
+
base_url=os.environ["GRAVITY_LLM_URL"],
|
|
28
|
+
api_key=token,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# tools -> gravity's gateway over mcp. It only serves what the manifest
|
|
32
|
+
# declared, so this list is exactly permissions.tools.
|
|
33
|
+
client = MultiServerMCPClient(
|
|
34
|
+
{
|
|
35
|
+
"gravity": {
|
|
36
|
+
"transport": "streamable_http",
|
|
37
|
+
"url": os.environ["GRAVITY_GATEWAY_URL"],
|
|
38
|
+
"headers": {"Authorization": f"Bearer {token}"},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
tools = await client.get_tools()
|
|
43
|
+
react = create_react_agent(
|
|
44
|
+
model,
|
|
45
|
+
tools,
|
|
46
|
+
prompt="you help with whatever the user's query asks, using the tools available to you.",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
async def run_agent(state: State):
|
|
50
|
+
query = state["inputs"].get("query", "")
|
|
51
|
+
result = await react.ainvoke({"messages": [{"role": "user", "content": query}]})
|
|
52
|
+
return {"result": result["messages"][-1].content}
|
|
53
|
+
|
|
54
|
+
workflow = StateGraph(State)
|
|
55
|
+
workflow.add_node("agent", run_agent)
|
|
56
|
+
workflow.set_entry_point("agent")
|
|
57
|
+
workflow.add_edge("agent", END)
|
|
58
|
+
return workflow.compile()
|