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.
Files changed (46) hide show
  1. gravity_cli/__init__.py +11 -0
  2. gravity_cli/__main__.py +4 -0
  3. gravity_cli/_runner_script.py +195 -0
  4. gravity_cli/api.py +88 -0
  5. gravity_cli/cli.py +124 -0
  6. gravity_cli/commands/__init__.py +0 -0
  7. gravity_cli/commands/adapt.py +155 -0
  8. gravity_cli/commands/build.py +83 -0
  9. gravity_cli/commands/init.py +78 -0
  10. gravity_cli/commands/login.py +58 -0
  11. gravity_cli/commands/push.py +98 -0
  12. gravity_cli/commands/run.py +281 -0
  13. gravity_cli/commands/runs.py +49 -0
  14. gravity_cli/commands/schedule.py +74 -0
  15. gravity_cli/commands/tools.py +27 -0
  16. gravity_cli/commands/validate.py +195 -0
  17. gravity_cli/config.py +91 -0
  18. gravity_cli/manifest.py +19 -0
  19. gravity_cli/project.py +42 -0
  20. gravity_cli/templates/chat/.gitignore +6 -0
  21. gravity_cli/templates/chat/manifest.yaml +28 -0
  22. gravity_cli/templates/chat/requirements.txt +3 -0
  23. gravity_cli/templates/chat/src/agent.py +80 -0
  24. gravity_cli/templates/deepagent/.gitignore +6 -0
  25. gravity_cli/templates/deepagent/manifest.yaml +28 -0
  26. gravity_cli/templates/deepagent/requirements.txt +4 -0
  27. gravity_cli/templates/deepagent/src/agent.py +54 -0
  28. gravity_cli/templates/minimal/.gitignore +6 -0
  29. gravity_cli/templates/minimal/manifest.yaml +30 -0
  30. gravity_cli/templates/minimal/requirements.txt +3 -0
  31. gravity_cli/templates/minimal/src/agent.py +58 -0
  32. gravity_cli/templates/structured/.gitignore +6 -0
  33. gravity_cli/templates/structured/manifest.yaml +33 -0
  34. gravity_cli/templates/structured/requirements.txt +3 -0
  35. gravity_cli/templates/structured/src/agent.py +15 -0
  36. gravity_cli/templates/structured/src/gateway.py +26 -0
  37. gravity_cli/templates/structured/src/nodes/agent.py +20 -0
  38. gravity_cli/templates/structured/src/prompts.py +6 -0
  39. gravity_cli/templates/structured/src/state.py +10 -0
  40. gravity_cli/timeline.py +254 -0
  41. gravity_cli-0.1.0.dist-info/METADATA +494 -0
  42. gravity_cli-0.1.0.dist-info/RECORD +46 -0
  43. gravity_cli-0.1.0.dist-info/WHEEL +4 -0
  44. gravity_cli-0.1.0.dist-info/entry_points.txt +2 -0
  45. gravity_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  46. 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)
@@ -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,6 @@
1
+ build/
2
+ .venv/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .gravity/
6
+ .env
@@ -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,3 @@
1
+ langgraph>=1.0,<2.0
2
+ langchain-openai>=1.0,<2.0
3
+ langchain-mcp-adapters>=0.3,<0.4
@@ -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,6 @@
1
+ build/
2
+ .venv/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .gravity/
6
+ .env
@@ -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,4 @@
1
+ langgraph>=1.0,<2.0
2
+ langchain-openai>=1.0,<2.0
3
+ langchain-mcp-adapters>=0.3,<0.4
4
+ deepagents>=0.5,<0.6
@@ -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,6 @@
1
+ build/
2
+ .venv/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .gravity/
6
+ .env
@@ -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,3 @@
1
+ langgraph>=1.0,<2.0
2
+ langchain-openai>=1.0,<2.0
3
+ langchain-mcp-adapters>=0.3,<0.4
@@ -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()
@@ -0,0 +1,6 @@
1
+ build/
2
+ .venv/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .gravity/
6
+ .env