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
gravity_cli/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""gravity — the CLI a builder actually touches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
# Windows consoles default to cp1252, which can't print ✓/✖/→. Here so nothing prints first.
|
|
9
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
10
|
+
with contextlib.suppress(Exception):
|
|
11
|
+
_stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
gravity_cli/__main__.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Runs the builder's agent in their own venv, as the production runner does. Stdlib only.
|
|
2
|
+
|
|
3
|
+
argv: entry_file entry_object payload_file a JSON file: {"inputs", "messages"?, "memory"?}
|
|
4
|
+
out: one JSON event per line (EVENT_PREFIX) for `gravity run` to draw."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import importlib.util
|
|
10
|
+
import inspect
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import warnings
|
|
16
|
+
|
|
17
|
+
# A harmless langchain-openai warning that would print mid-timeline.
|
|
18
|
+
warnings.filterwarnings("ignore", message=r"Pydantic serializer warnings")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_graph(entry_file: str, entry_object: str):
|
|
22
|
+
# src/ on sys.path, as in production, so a relative import fails here too.
|
|
23
|
+
file_path = os.path.abspath(entry_file)
|
|
24
|
+
pkg_root = os.path.dirname(file_path)
|
|
25
|
+
if pkg_root not in sys.path:
|
|
26
|
+
sys.path.insert(0, pkg_root)
|
|
27
|
+
spec = importlib.util.spec_from_file_location("gravity_agent", file_path)
|
|
28
|
+
if spec is None or spec.loader is None:
|
|
29
|
+
raise ImportError(f"could not load {entry_file}")
|
|
30
|
+
module = importlib.util.module_from_spec(spec)
|
|
31
|
+
sys.modules["gravity_agent"] = module
|
|
32
|
+
spec.loader.exec_module(module)
|
|
33
|
+
if not hasattr(module, entry_object):
|
|
34
|
+
raise AttributeError(
|
|
35
|
+
f"{entry_file} has no `{entry_object}` — check runtime.entrypoint in manifest.yaml"
|
|
36
|
+
)
|
|
37
|
+
return getattr(module, entry_object)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _resolve(obj):
|
|
41
|
+
# Same rules as the production runner: await, or call a zero-arg factory, in this loop.
|
|
42
|
+
if inspect.isawaitable(obj):
|
|
43
|
+
return await obj
|
|
44
|
+
if callable(obj) and not hasattr(obj, "astream"):
|
|
45
|
+
obj = obj()
|
|
46
|
+
if inspect.isawaitable(obj):
|
|
47
|
+
obj = await obj
|
|
48
|
+
return obj
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
EVENT_PREFIX = "@@gravity "
|
|
52
|
+
ROLES = {"human": "user", "ai": "assistant", "system": "system"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Same three helpers as agentcore-spike/runner/gravity_runner/__main__.py.
|
|
56
|
+
def accepts_messages(graph) -> bool:
|
|
57
|
+
# A compiled graph's channels are its state keys; no schema build (raises on py<3.12).
|
|
58
|
+
channels = getattr(graph, "channels", None)
|
|
59
|
+
if isinstance(channels, dict):
|
|
60
|
+
return "messages" in channels
|
|
61
|
+
getter = getattr(graph, "get_input_jsonschema", None)
|
|
62
|
+
if callable(getter):
|
|
63
|
+
try:
|
|
64
|
+
return "messages" in (getter().get("properties") or {})
|
|
65
|
+
except Exception: # noqa: BLE001, S110 - a probe; absence is a valid answer
|
|
66
|
+
pass
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def user_turn(inputs: dict) -> str:
|
|
71
|
+
for key in ("message", "query", "prompt", "input"):
|
|
72
|
+
value = inputs.get(key)
|
|
73
|
+
if isinstance(value, str) and value.strip():
|
|
74
|
+
return value
|
|
75
|
+
return json.dumps(inputs)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def to_dicts(messages) -> list[dict]:
|
|
79
|
+
"""LangChain messages -> {role, content}; tool turns dropped (half a pair breaks the next turn)."""
|
|
80
|
+
out = []
|
|
81
|
+
for m in messages or []:
|
|
82
|
+
if isinstance(m, dict):
|
|
83
|
+
role, content = m.get("role"), m.get("content")
|
|
84
|
+
else:
|
|
85
|
+
if getattr(m, "tool_calls", None) or getattr(m, "type", None) == "tool":
|
|
86
|
+
continue
|
|
87
|
+
role, content = ROLES.get(getattr(m, "type", ""), ""), getattr(m, "content", None)
|
|
88
|
+
if role in ROLES.values() and isinstance(content, str) and content.strip():
|
|
89
|
+
out.append({"role": role, "content": content})
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def emit(type_: str, **fields) -> None:
|
|
94
|
+
line = json.dumps({"type": type_, **fields}, default=str, ensure_ascii=False)
|
|
95
|
+
print(EVENT_PREFIX + line, flush=True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _text(value) -> str:
|
|
99
|
+
"""A tool's output as text: ToolMessage, MCP content blocks, or a str."""
|
|
100
|
+
value = getattr(value, "content", value)
|
|
101
|
+
if isinstance(value, list):
|
|
102
|
+
value = "".join(b.get("text", "") if isinstance(b, dict) else str(b) for b in value)
|
|
103
|
+
return value if isinstance(value, str) else json.dumps(value, default=str)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _handle(ev: dict, started: dict) -> None:
|
|
107
|
+
"""One LangGraph astream_events(v2) event -> zero or one gravity event."""
|
|
108
|
+
kind, name = ev["event"], ev["name"]
|
|
109
|
+
meta = ev.get("metadata") or {}
|
|
110
|
+
node = meta.get("langgraph_node")
|
|
111
|
+
top_level = "|" not in (meta.get("langgraph_checkpoint_ns") or "")
|
|
112
|
+
run_id = ev.get("run_id")
|
|
113
|
+
|
|
114
|
+
if kind in ("on_chain_start", "on_chain_end") and name == node and top_level:
|
|
115
|
+
if kind == "on_chain_start":
|
|
116
|
+
started[run_id] = time.perf_counter()
|
|
117
|
+
emit("step.started", node=name)
|
|
118
|
+
else:
|
|
119
|
+
emit("step.finished", node=name, ms=_ms(started, run_id))
|
|
120
|
+
elif kind == "on_chat_model_start":
|
|
121
|
+
started[run_id] = time.perf_counter()
|
|
122
|
+
elif kind == "on_chat_model_end":
|
|
123
|
+
emit("model.called", node=node, model=meta.get("ls_model_name") or name, ms=_ms(started, run_id))
|
|
124
|
+
elif kind == "on_tool_start":
|
|
125
|
+
# At the start too, so a call held for approval is visible while it waits.
|
|
126
|
+
started[run_id] = time.perf_counter()
|
|
127
|
+
emit("tool.started", node=node, tool=name, id=run_id)
|
|
128
|
+
elif kind == "on_tool_end":
|
|
129
|
+
emit("tool.called", node=node, tool=name, id=run_id, ms=_ms(started, run_id), output=_text(ev["data"].get("output"))[:100_000])
|
|
130
|
+
elif kind == "on_tool_error":
|
|
131
|
+
emit("tool.failed", node=node, tool=name, id=run_id, ms=_ms(started, run_id), error=str(ev["data"].get("error")))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _ms(started: dict, run_id) -> int | None:
|
|
135
|
+
t = started.pop(run_id, None)
|
|
136
|
+
return round((time.perf_counter() - t) * 1000) if t else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _consume_async(graph, state, history):
|
|
140
|
+
graph = await _resolve(graph)
|
|
141
|
+
if accepts_messages(graph):
|
|
142
|
+
state["messages"] = history or [{"role": "user", "content": user_turn(state["inputs"])}]
|
|
143
|
+
if not hasattr(graph, "astream_events"):
|
|
144
|
+
# Not LangGraph: no timeline; plain astream(state), folding the deltas.
|
|
145
|
+
final: dict = {}
|
|
146
|
+
async for update in graph.astream(state):
|
|
147
|
+
if isinstance(update, dict):
|
|
148
|
+
for value in update.values() if all(isinstance(v, dict) for v in update.values()) else [update]:
|
|
149
|
+
final.update(value)
|
|
150
|
+
return final
|
|
151
|
+
final, started = None, {}
|
|
152
|
+
async for ev in graph.astream_events(state, version="v2"):
|
|
153
|
+
_handle(ev, started)
|
|
154
|
+
if ev["event"] == "on_chain_end" and not ev.get("parent_ids"):
|
|
155
|
+
final = ev["data"].get("output")
|
|
156
|
+
return final
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main() -> int:
|
|
160
|
+
entry_file, entry_object = sys.argv[1], sys.argv[2]
|
|
161
|
+
with open(sys.argv[3], encoding="utf-8") as f:
|
|
162
|
+
payload = json.load(f)
|
|
163
|
+
history = payload.get("messages")
|
|
164
|
+
state = {"inputs": payload.get("inputs") or {}}
|
|
165
|
+
if "memory" in payload:
|
|
166
|
+
state["memory"] = payload["memory"] or {}
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
graph = _load_graph(entry_file, entry_object)
|
|
170
|
+
except Exception as e: # noqa: BLE001 — surfaced to the builder, not swallowed
|
|
171
|
+
emit("run.failed", stage="load", error=f"could not load the agent: {e}")
|
|
172
|
+
return 1
|
|
173
|
+
|
|
174
|
+
# astream: agent graphs have async nodes, so the sync stream() fails on them.
|
|
175
|
+
start = time.time()
|
|
176
|
+
try:
|
|
177
|
+
final = asyncio.run(_consume_async(graph, state, history))
|
|
178
|
+
except Exception as e: # noqa: BLE001
|
|
179
|
+
emit("run.failed", stage="run", error=str(e), ms=round((time.time() - start) * 1000))
|
|
180
|
+
return 1
|
|
181
|
+
|
|
182
|
+
final = final if isinstance(final, dict) else {}
|
|
183
|
+
emit(
|
|
184
|
+
"run.finished",
|
|
185
|
+
ms=round((time.time() - start) * 1000),
|
|
186
|
+
has_result="result" in final,
|
|
187
|
+
result=final.get("result"),
|
|
188
|
+
messages=to_dicts(final.get("messages")) if "messages" in state else None,
|
|
189
|
+
memory=final.get("memory") if "memory" in state else None,
|
|
190
|
+
)
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
sys.exit(main())
|
gravity_cli/api.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Thin client for the control plane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
from rich import print as rprint
|
|
11
|
+
|
|
12
|
+
from . import config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _handle(resp: httpx.Response) -> Any:
|
|
16
|
+
"""The JSON body, or print the server's message and exit 1."""
|
|
17
|
+
if resp.status_code >= 400:
|
|
18
|
+
try:
|
|
19
|
+
body = resp.json()
|
|
20
|
+
message = body.get("message") or body
|
|
21
|
+
except (ValueError, AttributeError): # not JSON, or not an object
|
|
22
|
+
message = resp.text
|
|
23
|
+
if isinstance(message, list):
|
|
24
|
+
message = "; ".join(str(m) for m in message)
|
|
25
|
+
rprint(f"[red]✖[/red] {message}")
|
|
26
|
+
raise typer.Exit(1)
|
|
27
|
+
if resp.status_code == 204 or not resp.content:
|
|
28
|
+
return None
|
|
29
|
+
return resp.json()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _post(path: str, **kw) -> Any:
|
|
33
|
+
"""Unauthenticated: the device-code login runs before there is a token."""
|
|
34
|
+
with httpx.Client(base_url=config.api_url(), timeout=15) as client:
|
|
35
|
+
return _handle(client.post(path, **kw))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def device_start() -> dict:
|
|
39
|
+
return _post("/api/cli/device/start")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def device_poll(device_code: str) -> dict:
|
|
43
|
+
return _post("/api/cli/device/poll", json={"deviceCode": device_code})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def require_session() -> config.Session:
|
|
47
|
+
session = config.load_session()
|
|
48
|
+
if session is None:
|
|
49
|
+
typer.secho(
|
|
50
|
+
"not logged in — run `gravity login` first, or set GRAVITY_API_TOKEN.",
|
|
51
|
+
fg=typer.colors.RED,
|
|
52
|
+
err=True,
|
|
53
|
+
)
|
|
54
|
+
raise typer.Exit(1)
|
|
55
|
+
return session
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _token() -> str:
|
|
59
|
+
# GRAVITY_API_TOKEN (the control plane's dev token) wins over a stored session.
|
|
60
|
+
return os.environ.get("GRAVITY_API_TOKEN", "").strip() or require_session().access_token
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _auth_client() -> httpx.Client:
|
|
64
|
+
return httpx.Client(
|
|
65
|
+
base_url=config.api_url(),
|
|
66
|
+
timeout=60,
|
|
67
|
+
headers={"Authorization": f"Bearer {_token()}"},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get(path: str) -> Any:
|
|
72
|
+
with _auth_client() as client:
|
|
73
|
+
return _handle(client.get(path))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def push_agent(manifest_json: dict, zip_bytes: bytes, source_sha256: str) -> dict:
|
|
77
|
+
import base64
|
|
78
|
+
import json
|
|
79
|
+
|
|
80
|
+
headers = {
|
|
81
|
+
"Authorization": f"Bearer {_token()}",
|
|
82
|
+
"Content-Type": "application/octet-stream",
|
|
83
|
+
"x-manifest": base64.b64encode(json.dumps(manifest_json).encode()).decode(),
|
|
84
|
+
"x-source-sha256": source_sha256,
|
|
85
|
+
}
|
|
86
|
+
with httpx.Client(base_url=config.api_url(), timeout=120, headers=headers) as client:
|
|
87
|
+
resp = client.post("/api/cli/agents/push", content=zip_bytes)
|
|
88
|
+
return _handle(resp)
|
gravity_cli/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""gravity CLI. Convenience only, never security: the server re-checks everything."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
import typer.rich_utils as help_style
|
|
9
|
+
from rich.console import Console, Group
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.prompt import Confirm, Prompt
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from . import config
|
|
16
|
+
from .commands.adapt import adapt_command
|
|
17
|
+
from .commands.build import build_command
|
|
18
|
+
from .commands.init import TEMPLATE_NAMES, init_command
|
|
19
|
+
from .commands.login import login_command, logout_command, whoami_command
|
|
20
|
+
from .commands.push import push_command
|
|
21
|
+
from .commands.run import run_command
|
|
22
|
+
from .commands.runs import agents_command, logs_command, runs_command
|
|
23
|
+
from .commands.schedule import schedule_command
|
|
24
|
+
from .commands.tools import tools_command
|
|
25
|
+
from .commands.validate import validate_command
|
|
26
|
+
|
|
27
|
+
EMBER, AMBER, RED = "#ff6b35", "#ffb627", "#e63946"
|
|
28
|
+
|
|
29
|
+
help_style.STYLE_USAGE = f"bold {EMBER}"
|
|
30
|
+
help_style.STYLE_COMMANDS_TABLE_FIRST_COLUMN = f"bold {EMBER}"
|
|
31
|
+
help_style.STYLE_OPTION = help_style.STYLE_SWITCH = f"bold {AMBER}"
|
|
32
|
+
help_style.STYLE_COMMANDS_PANEL_BORDER = help_style.STYLE_OPTIONS_PANEL_BORDER = AMBER
|
|
33
|
+
help_style.STYLE_ERRORS_PANEL_BORDER = RED
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(
|
|
36
|
+
name="gravity",
|
|
37
|
+
help="Build, test and publish AI agents on Gravity.",
|
|
38
|
+
epilog=(
|
|
39
|
+
f"[bold {EMBER}]New here?[/] gravity init my-agent → cd my-agent → gravity run --input query=\"hi\"\n\n"
|
|
40
|
+
"Settings live in ~/.gravity/.env. Add -h to any command for its options."
|
|
41
|
+
),
|
|
42
|
+
rich_markup_mode="rich",
|
|
43
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
44
|
+
add_completion=False,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
BUILD, PUBLISH, AGENTS, ACCOUNT = "Build an agent", "Publish", "Your agents", "Account"
|
|
48
|
+
for name, command, panel, summary in [
|
|
49
|
+
("init", init_command, BUILD, "Create a new agent from a template"),
|
|
50
|
+
("validate", validate_command, BUILD, "Check your agent for mistakes before it runs"),
|
|
51
|
+
("run", run_command, BUILD, "Run your agent locally against real tools"),
|
|
52
|
+
("schedule", schedule_command, BUILD, "Run your agent on its schedule, locally"),
|
|
53
|
+
("adapt", adapt_command, BUILD, "Write a new slot adapter with AI, kept only if its tests pass"),
|
|
54
|
+
("build", build_command, PUBLISH, "Package your agent into build/agent.zip"),
|
|
55
|
+
("push", push_command, PUBLISH, "Publish your built agent to Gravity"),
|
|
56
|
+
("agents", agents_command, AGENTS, "List your published agents and their review status"),
|
|
57
|
+
("runs", runs_command, AGENTS, "Show recent runs of one agent"),
|
|
58
|
+
("logs", logs_command, AGENTS, "Show the logs of one run"),
|
|
59
|
+
("tools", tools_command, AGENTS, "Browse the tools your agent can use"),
|
|
60
|
+
("login", login_command, ACCOUNT, "Sign in to Gravity in your browser"),
|
|
61
|
+
("logout", logout_command, ACCOUNT, "Sign out"),
|
|
62
|
+
("whoami", whoami_command, ACCOUNT, "Show who you are signed in as"),
|
|
63
|
+
]:
|
|
64
|
+
app.command(name, help=summary, rich_help_panel=panel)(command)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("help", rich_help_panel=ACCOUNT)
|
|
68
|
+
def help_command(ctx: typer.Context):
|
|
69
|
+
"""Show this list of commands"""
|
|
70
|
+
ctx.parent.get_help()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _interactive() -> bool:
|
|
74
|
+
return sys.stdin.isatty() and sys.stderr.isatty() # a person is there to answer
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _welcome(console: Console) -> None:
|
|
78
|
+
steps = Table.grid(padding=(0, 2))
|
|
79
|
+
steps.add_column(style=f"bold {EMBER}", justify="right")
|
|
80
|
+
steps.add_column(style=f"bold {AMBER}")
|
|
81
|
+
steps.add_column(style="dim")
|
|
82
|
+
steps.add_row("1", "gravity init my-agent", "create a working agent from a template")
|
|
83
|
+
steps.add_row("2", "gravity validate", "catch mistakes before anything runs")
|
|
84
|
+
steps.add_row("3", "gravity run", "run it against real tools, live")
|
|
85
|
+
steps.add_row("4", "gravity push", "publish it to Gravity")
|
|
86
|
+
body = Group(
|
|
87
|
+
Text.from_markup(
|
|
88
|
+
"Build AI agents in plain Python with LangGraph. Declare what they may touch in one "
|
|
89
|
+
"[bold]manifest.yaml[/bold], and Gravity runs them with exactly those permissions.\n"
|
|
90
|
+
),
|
|
91
|
+
steps,
|
|
92
|
+
Text.from_markup(f"\n[dim]Stuck? [bold {AMBER}]gravity help[/] lists every command.[/dim]"),
|
|
93
|
+
)
|
|
94
|
+
console.print(Panel(body, title=f"[bold {EMBER}]✦ Welcome to Gravity ✦", border_style=AMBER, padding=(1, 2), expand=False))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _first_agent(console: Console) -> bool:
|
|
98
|
+
"""Offer to scaffold an agent right away. True if one was created."""
|
|
99
|
+
try:
|
|
100
|
+
if not Confirm.ask(f"[bold {AMBER}]Create your first agent now?", default=True, console=console):
|
|
101
|
+
return False
|
|
102
|
+
name = Prompt.ask(" Agent name", default="my-agent", console=console)
|
|
103
|
+
template = Prompt.ask(" Template", choices=TEMPLATE_NAMES, default="minimal", console=console)
|
|
104
|
+
except (EOFError, KeyboardInterrupt):
|
|
105
|
+
console.print()
|
|
106
|
+
return False
|
|
107
|
+
init_command(name, template=template, here=False)
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.callback(invoke_without_command=True)
|
|
112
|
+
def main(
|
|
113
|
+
ctx: typer.Context,
|
|
114
|
+
api_url: str = typer.Option(None, "--api-url", help="Save a different Gravity API URL (or set GRAVITY_API_URL)"),
|
|
115
|
+
):
|
|
116
|
+
if config.first_run():
|
|
117
|
+
console = Console(stderr=True) # stderr: never mixed into output someone pipes
|
|
118
|
+
_welcome(console)
|
|
119
|
+
if ctx.invoked_subcommand is None and _interactive() and _first_agent(console):
|
|
120
|
+
raise typer.Exit()
|
|
121
|
+
if api_url:
|
|
122
|
+
config.set_api_url(api_url)
|
|
123
|
+
if ctx.invoked_subcommand is None:
|
|
124
|
+
ctx.get_help()
|
|
File without changes
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""`gravity adapt <slot> <name> --tools ...`: a coding agent writes one adapter file, kept only
|
|
2
|
+
if the slot's conformance tests pass. Naming the tools is the grant."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
from rich import print as rprint
|
|
15
|
+
from rich.markup import escape
|
|
16
|
+
|
|
17
|
+
from .. import project
|
|
18
|
+
from ..manifest import ManifestValidationError, load_manifest, render_issues
|
|
19
|
+
from .run import BUILDER_TOKENS, _mint_run_token, gateway_urls
|
|
20
|
+
|
|
21
|
+
ATTEMPTS = 3
|
|
22
|
+
CODE_BLOCK = re.compile(r"```python\n(.*?)```", re.DOTALL)
|
|
23
|
+
|
|
24
|
+
SYSTEM = (
|
|
25
|
+
"You write one adapter file for a Python agent. Follow the slot contract exactly. Reach "
|
|
26
|
+
"the outside world only through the `call` function you are given, only with the tools "
|
|
27
|
+
"you are told this adapter may use, with arguments that match their schemas. Stdlib and "
|
|
28
|
+
"the slot package only. Match the style of the existing adapters. Reply with the whole "
|
|
29
|
+
"file in one ```python block and nothing else."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _declare(manifest_path: Path, slot: str, name: str, tools: list[str]) -> str:
|
|
34
|
+
"""Add `<name>: [tools]` under slots.<slot>.adapters. Returns the original text."""
|
|
35
|
+
original = manifest_path.read_text(encoding="utf-8")
|
|
36
|
+
lines = original.splitlines(keepends=True)
|
|
37
|
+
slot_at = next(i for i, ln in enumerate(lines) if re.match(rf"^\s+{slot}:\s*$", ln))
|
|
38
|
+
adapters_at = next(i for i in range(slot_at + 1, len(lines)) if lines[i].strip() == "adapters:")
|
|
39
|
+
indent = len(lines[adapters_at]) - len(lines[adapters_at].lstrip())
|
|
40
|
+
end = adapters_at + 1
|
|
41
|
+
while end < len(lines) and (not lines[end].strip() or len(lines[end]) - len(lines[end].lstrip()) > indent):
|
|
42
|
+
end += 1
|
|
43
|
+
while not lines[end - 1].strip(): # keep the new entry above any blank line
|
|
44
|
+
end -= 1
|
|
45
|
+
lines.insert(end, f"{' ' * (indent + 2)}{name}: [{', '.join(tools)}]\n")
|
|
46
|
+
manifest_path.write_text("".join(lines), encoding="utf-8")
|
|
47
|
+
return original
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ask(gateway_base: str, token: str, model: str, messages: list[dict]) -> str:
|
|
51
|
+
resp = httpx.post(
|
|
52
|
+
f"{gateway_base}/v1/chat/completions",
|
|
53
|
+
json={"model": model, "messages": messages},
|
|
54
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
55
|
+
timeout=180,
|
|
56
|
+
)
|
|
57
|
+
resp.raise_for_status()
|
|
58
|
+
return resp.json()["choices"][0]["message"]["content"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def adapt_command(
|
|
62
|
+
slot: str = typer.Argument(..., help="The slot to extend, e.g. deliver"),
|
|
63
|
+
name: str = typer.Argument(..., help="Name of the new adapter, e.g. slack"),
|
|
64
|
+
tools: str = typer.Option(..., "--tools", help="Tools the adapter may call, comma-separated. This is what it is granted"),
|
|
65
|
+
hint: str = typer.Option("", "--hint", help="Anything the coding agent should know, e.g. 'post to #leads'"),
|
|
66
|
+
model: str = typer.Option("anthropic/claude-sonnet-5", "--model", help="Model that writes the adapter"),
|
|
67
|
+
path: Path = typer.Option(Path("."), "--path", help="Agent folder"),
|
|
68
|
+
):
|
|
69
|
+
"""Write a new adapter for a slot with a coding agent, gated by the slot's conformance tests."""
|
|
70
|
+
project_dir = path.resolve()
|
|
71
|
+
manifest_path = project_dir / "manifest.yaml"
|
|
72
|
+
manifest = load_manifest(manifest_path)
|
|
73
|
+
if slot not in manifest.slots:
|
|
74
|
+
rprint(f"[red]✖[/red] no slot {slot!r} in manifest.yaml (slots: {', '.join(manifest.slots) or 'none'})")
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
if not name.isidentifier() or name in manifest.slots[slot].adapters:
|
|
77
|
+
rprint(f"[red]✖[/red] {name!r} is not a new adapter name for {slot}")
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
folder = project_dir / "src" / "adapters" / slot
|
|
80
|
+
target = folder / f"{name}.py"
|
|
81
|
+
test_file = project_dir / "tests" / f"test_{slot}_adapters.py"
|
|
82
|
+
if not test_file.is_file():
|
|
83
|
+
rprint(f"[red]✖[/red] no tests/test_{slot}_adapters.py — the conformance tests are what make this safe")
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
grant = [t.strip() for t in tools.split(",") if t.strip()]
|
|
86
|
+
|
|
87
|
+
# Declare first so validation (and the tests) see the adapter with exactly its grant.
|
|
88
|
+
original = _declare(manifest_path, slot, name, grant)
|
|
89
|
+
try:
|
|
90
|
+
load_manifest(manifest_path) # catalog check on the new tools
|
|
91
|
+
except ManifestValidationError as e:
|
|
92
|
+
manifest_path.write_text(original, encoding="utf-8")
|
|
93
|
+
render_issues(e)
|
|
94
|
+
raise typer.Exit(1) from e
|
|
95
|
+
|
|
96
|
+
_, gateway_base = gateway_urls()
|
|
97
|
+
dev = {"Authorization": f"Bearer {os.environ.get('GRAVITY_DEV_TOKEN', '')}"}
|
|
98
|
+
all_tools = sorted({t for tl in manifest.slots[slot].adapters.values() for t in tl} | set(grant))
|
|
99
|
+
try:
|
|
100
|
+
schemas = httpx.get(f"{gateway_base}/tools", params={"names": ",".join(all_tools)}, headers=dev, timeout=15)
|
|
101
|
+
schemas.raise_for_status()
|
|
102
|
+
except httpx.HTTPError as e:
|
|
103
|
+
manifest_path.write_text(original, encoding="utf-8")
|
|
104
|
+
rprint(f"[red]✖[/red] couldn't read tool schemas from the gateway at {gateway_base}: {e}")
|
|
105
|
+
raise typer.Exit(1) from e
|
|
106
|
+
(project_dir / "tests" / "tool_schemas.json").write_text(json.dumps(schemas.json(), indent=2), encoding="utf-8")
|
|
107
|
+
|
|
108
|
+
def read(p: Path) -> str:
|
|
109
|
+
return f"### {p.relative_to(project_dir).as_posix()}\n```python\n{p.read_text(encoding='utf-8')}```"
|
|
110
|
+
|
|
111
|
+
gold = [read(p) for p in sorted(folder.glob("*.py")) if p != target]
|
|
112
|
+
new_schemas = [s for s in schemas.json() if s["name"] in grant]
|
|
113
|
+
messages = [
|
|
114
|
+
{"role": "system", "content": SYSTEM},
|
|
115
|
+
{"role": "user", "content": "\n\n".join([
|
|
116
|
+
f"Write src/adapters/{slot}/{name}.py. It may call only: {', '.join(grant)}.",
|
|
117
|
+
f"Their argument schemas:\n```json\n{json.dumps(new_schemas, indent=2)}\n```",
|
|
118
|
+
"The slot contract and the existing adapters:", *gold,
|
|
119
|
+
"It must pass these conformance tests:", read(test_file),
|
|
120
|
+
*( [f"Notes from the user: {hint}"] if hint else [] ),
|
|
121
|
+
])},
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
token = _mint_run_token(gateway_base, [], [])["token"] # models only, no tools
|
|
125
|
+
python = project.find_python(project_dir)
|
|
126
|
+
test_env = {k: v for k, v in os.environ.items() if k not in BUILDER_TOKENS} # it runs LLM-written code
|
|
127
|
+
for attempt in range(1, ATTEMPTS + 1):
|
|
128
|
+
rprint(f"[dim]attempt {attempt}/{ATTEMPTS}: asking {model}…[/dim]")
|
|
129
|
+
reply = _ask(gateway_base, token, model, messages)
|
|
130
|
+
match = CODE_BLOCK.search(reply)
|
|
131
|
+
code = match.group(1) if match else reply
|
|
132
|
+
target.write_text(code, encoding="utf-8") # the only file it ever writes
|
|
133
|
+
result = subprocess.run(
|
|
134
|
+
[python, "-m", "pytest", "-q", str(test_file), "-k", name],
|
|
135
|
+
cwd=project_dir, env=test_env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False,
|
|
136
|
+
)
|
|
137
|
+
if result.returncode == 0:
|
|
138
|
+
rprint(f"[green]✓[/green] src/adapters/{slot}/{name}.py passes the {slot} conformance tests")
|
|
139
|
+
rprint(f"[green]✓[/green] declared in manifest.yaml: slots.{slot}.adapters.{name}: " + escape(f"[{', '.join(grant)}]"))
|
|
140
|
+
rprint(f" try it: [bold]gravity run --input {slot}={name} --input destination=…[/bold]")
|
|
141
|
+
return
|
|
142
|
+
failure = (result.stdout + result.stderr)[-3000:]
|
|
143
|
+
if "No module named pytest" in failure:
|
|
144
|
+
break
|
|
145
|
+
rprint("[yellow]✗[/yellow] tests failed; feeding the failure back")
|
|
146
|
+
messages += [{"role": "assistant", "content": reply},
|
|
147
|
+
{"role": "user", "content": f"The conformance tests failed:\n```\n{failure}\n```\nFix the file."}]
|
|
148
|
+
|
|
149
|
+
target.unlink(missing_ok=True)
|
|
150
|
+
manifest_path.write_text(original, encoding="utf-8")
|
|
151
|
+
rprint(f"[red]✖[/red] no adapter that passes the tests after {attempt} attempt(s); nothing was changed")
|
|
152
|
+
rprint("[dim]" + escape(failure.strip()[-1500:]) + "[/dim]")
|
|
153
|
+
if "No module named pytest" in failure:
|
|
154
|
+
rprint("[dim]install pytest in the agent's venv: uv pip install pytest pyyaml[/dim]")
|
|
155
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import zipfile
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich import print as rprint
|
|
12
|
+
|
|
13
|
+
from ..manifest import ManifestValidationError, load_manifest, render_issues
|
|
14
|
+
from ..project import project_files, source_digest
|
|
15
|
+
from .validate import validate_command
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_paths(project_dir: Path) -> tuple[Path, Path]:
|
|
19
|
+
"""(zip, meta) — the two files `gravity build` writes and `gravity push` reads."""
|
|
20
|
+
d = project_dir / "build"
|
|
21
|
+
return d / "agent.zip", d / "build.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _zip_project(project_dir: Path) -> bytes:
|
|
25
|
+
"""Deterministic (fixed timestamps, sorted entries): same source, same sha256."""
|
|
26
|
+
buf = io.BytesIO()
|
|
27
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
28
|
+
for f in project_files(project_dir):
|
|
29
|
+
info = zipfile.ZipInfo(f.relative_to(project_dir).as_posix())
|
|
30
|
+
info.date_time = (1980, 1, 1, 0, 0, 0)
|
|
31
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
32
|
+
zf.writestr(info, f.read_bytes())
|
|
33
|
+
return buf.getvalue()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_command(
|
|
37
|
+
path: Path = typer.Argument(Path("."), help="Agent folder"),
|
|
38
|
+
skip_validate: bool = typer.Option(False, "--skip-validate", hidden=True),
|
|
39
|
+
):
|
|
40
|
+
"""Validate, then bundle src + manifest + lockfile into build/agent.zip.
|
|
41
|
+
Nothing leaves your machine — inspect the zip, then `gravity push`."""
|
|
42
|
+
project_dir = path.resolve()
|
|
43
|
+
|
|
44
|
+
if not skip_validate:
|
|
45
|
+
try:
|
|
46
|
+
validate_command(project_dir)
|
|
47
|
+
except typer.Exit as e:
|
|
48
|
+
if e.exit_code != 0:
|
|
49
|
+
rprint("[red]build aborted — fix the errors above and re-run.[/red]")
|
|
50
|
+
raise
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
manifest = load_manifest(project_dir / "manifest.yaml")
|
|
54
|
+
except ManifestValidationError as e:
|
|
55
|
+
render_issues(e)
|
|
56
|
+
raise typer.Exit(1) from e
|
|
57
|
+
|
|
58
|
+
# After validate recompiled the lock, so the digest covers what ships.
|
|
59
|
+
zip_bytes = _zip_project(project_dir)
|
|
60
|
+
digest = source_digest(project_dir)
|
|
61
|
+
zip_sha256 = hashlib.sha256(zip_bytes).hexdigest()
|
|
62
|
+
|
|
63
|
+
zip_path, meta_path = build_paths(project_dir)
|
|
64
|
+
zip_path.parent.mkdir(exist_ok=True)
|
|
65
|
+
zip_path.write_bytes(zip_bytes)
|
|
66
|
+
meta_path.write_text(
|
|
67
|
+
json.dumps(
|
|
68
|
+
{
|
|
69
|
+
"source_sha256": digest,
|
|
70
|
+
"zip_sha256": zip_sha256,
|
|
71
|
+
"built_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
72
|
+
},
|
|
73
|
+
indent=2,
|
|
74
|
+
)
|
|
75
|
+
+ "\n"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
rel = zip_path.relative_to(project_dir).as_posix()
|
|
79
|
+
rprint(
|
|
80
|
+
f"[green]✓ built {manifest.name}@{manifest.version}[/green] → {rel} "
|
|
81
|
+
f"· {len(zip_bytes)} bytes · sha256:{zip_sha256[:12]}…"
|
|
82
|
+
)
|
|
83
|
+
rprint("[dim]next: gravity push[/dim]")
|