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,33 @@
|
|
|
1
|
+
contract: v1
|
|
2
|
+
name: {{name}}
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: "a structured langgraph agent, scaffolded by gravity init"
|
|
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: query
|
|
17
|
+
type: text
|
|
18
|
+
label: "What should the agent do?"
|
|
19
|
+
required: true
|
|
20
|
+
- name: tone
|
|
21
|
+
type: static-dropdown
|
|
22
|
+
label: "Reply tone"
|
|
23
|
+
options: [formal, friendly, brief]
|
|
24
|
+
default: friendly
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
tools:
|
|
28
|
+
- example.echo
|
|
29
|
+
models:
|
|
30
|
+
tier: standard
|
|
31
|
+
|
|
32
|
+
resources:
|
|
33
|
+
memory_mb: 512
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# src/agent.py — assembles the graph from src/nodes/, state.py and prompts.py.
|
|
2
|
+
from langgraph.graph import END, StateGraph
|
|
3
|
+
from nodes.agent import build_node
|
|
4
|
+
from state import State
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# ← the object manifest.runtime.entrypoint points at. An async factory the
|
|
8
|
+
# runner calls fresh for every run — never build the graph at import time.
|
|
9
|
+
async def graph():
|
|
10
|
+
run_agent = await build_node()
|
|
11
|
+
workflow = StateGraph(State)
|
|
12
|
+
workflow.add_node("agent", run_agent)
|
|
13
|
+
workflow.set_entry_point("agent")
|
|
14
|
+
workflow.add_edge("agent", END)
|
|
15
|
+
return workflow.compile()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Model and tool clients in one place. Functions, not module-level clients: env vars are set per run."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
5
|
+
from langchain_openai import ChatOpenAI
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_model() -> ChatOpenAI:
|
|
9
|
+
# The run token is the api key; the llm gateway swaps it for the real one.
|
|
10
|
+
return ChatOpenAI(
|
|
11
|
+
model="openai/gpt-6-luna",
|
|
12
|
+
base_url=os.environ["GRAVITY_LLM_URL"],
|
|
13
|
+
api_key=os.environ["GRAVITY_RUN_TOKEN"],
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_mcp_client() -> MultiServerMCPClient:
|
|
18
|
+
return MultiServerMCPClient(
|
|
19
|
+
{
|
|
20
|
+
"gravity": {
|
|
21
|
+
"transport": "streamable_http",
|
|
22
|
+
"url": os.environ["GRAVITY_GATEWAY_URL"],
|
|
23
|
+
"headers": {"Authorization": f"Bearer {os.environ['GRAVITY_RUN_TOKEN']}"},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The one node this scaffold ships with. Imports are absolute from src/ (AGENT_STANDARD.md §4)."""
|
|
2
|
+
from gateway import make_mcp_client, make_model
|
|
3
|
+
from langgraph.prebuilt import create_react_agent
|
|
4
|
+
from prompts import SYSTEM_PROMPT
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def build_node():
|
|
8
|
+
tools = await make_mcp_client().get_tools()
|
|
9
|
+
react = create_react_agent(make_model(), tools, prompt=SYSTEM_PROMPT)
|
|
10
|
+
|
|
11
|
+
async def run_agent(state):
|
|
12
|
+
query = state["inputs"].get("query", "")
|
|
13
|
+
tone = state["inputs"].get("tone", "friendly")
|
|
14
|
+
result = await react.ainvoke(
|
|
15
|
+
{"messages": [{"role": "user", "content": f"[{tone} tone] {query}"}]}
|
|
16
|
+
)
|
|
17
|
+
# state["result"] is what the platform returns to the consumer.
|
|
18
|
+
return {"result": result["messages"][-1].content}
|
|
19
|
+
|
|
20
|
+
return run_agent
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Typed state, in its own module — split out once an agent has more than
|
|
2
|
+
one node worth naming."""
|
|
3
|
+
from typing import TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class State(TypedDict):
|
|
7
|
+
inputs: dict
|
|
8
|
+
# No `messages` here on purpose: this agent answers one question per run.
|
|
9
|
+
# Declaring it is how an agent opts into conversation — see the `chat` template.
|
|
10
|
+
result: str # set by the last node; the platform returns it
|
gravity_cli/timeline.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""`gravity run`'s live view: the runner's JSON events as a timeline, then a result card."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.console import Console, Group
|
|
11
|
+
from rich.live import Live
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.spinner import Spinner
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
EVENT_PREFIX = "@@gravity " # must match _runner_script.EVENT_PREFIX
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Row:
|
|
22
|
+
kind: str # step | model | tool
|
|
23
|
+
label: str
|
|
24
|
+
detail: str = ""
|
|
25
|
+
ms: int | None = None
|
|
26
|
+
status: str = "running" # running | awaiting | ok | refused | failed
|
|
27
|
+
key: str | None = None # the tool call's run id, so its end updates its row
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _seconds(ms: int | None) -> str:
|
|
31
|
+
return f"{ms / 1000:.1f}s" if ms is not None else ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pick(data: Any, keys=("identifier", "url", "id")) -> str | None:
|
|
35
|
+
"""The most useful reference in a tool's output (an issue key, a URL, an id)."""
|
|
36
|
+
if isinstance(data, dict):
|
|
37
|
+
for k in keys:
|
|
38
|
+
if isinstance(data.get(k), str | int):
|
|
39
|
+
return str(data[k])
|
|
40
|
+
for v in data.values():
|
|
41
|
+
if found := _pick(v, keys):
|
|
42
|
+
return found
|
|
43
|
+
elif isinstance(data, list):
|
|
44
|
+
for v in data:
|
|
45
|
+
if found := _pick(v, keys):
|
|
46
|
+
return found
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _tool_detail(output: str) -> str:
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(output)
|
|
53
|
+
except (json.JSONDecodeError, TypeError):
|
|
54
|
+
return ""
|
|
55
|
+
if isinstance(data, dict) and data.get("successful") is False:
|
|
56
|
+
return f"error: {data.get('error')}"
|
|
57
|
+
# A listing reads as how many it found, not as its first item's id.
|
|
58
|
+
records = _first_records(data)
|
|
59
|
+
if records is not None and len(records) != 1:
|
|
60
|
+
return f"{len(records)} found"
|
|
61
|
+
return _pick(data) or ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _first_records(data: Any) -> list | None:
|
|
65
|
+
"""The first list of objects in a payload, if it has one."""
|
|
66
|
+
if isinstance(data, list):
|
|
67
|
+
return data if data and all(isinstance(i, dict) for i in data) else None
|
|
68
|
+
if isinstance(data, dict):
|
|
69
|
+
for v in data.values():
|
|
70
|
+
if (found := _first_records(v)) is not None:
|
|
71
|
+
return found
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Timeline:
|
|
76
|
+
def __init__(self, console: Console, *, agent: str, run_token: str, tools: list[str]):
|
|
77
|
+
self.console = console
|
|
78
|
+
self.rows: list[Row] = []
|
|
79
|
+
self.gravity_name = {t.replace(".", "_"): t for t in tools}
|
|
80
|
+
self.outcome: dict | None = None
|
|
81
|
+
self.shown_from = 0 # rows above this are already on screen, above a prompt
|
|
82
|
+
short = f"{run_token[:4]}…{run_token[-4:]}" if len(run_token) > 12 else run_token
|
|
83
|
+
console.print()
|
|
84
|
+
console.print(
|
|
85
|
+
f" [bold]{agent}[/bold] [dim]· run {short} · {len(tools)} "
|
|
86
|
+
f"tool{'s' if len(tools) != 1 else ''} declared[/dim]\n"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# events
|
|
90
|
+
|
|
91
|
+
def handle(self, ev: dict) -> None:
|
|
92
|
+
t = ev.get("type")
|
|
93
|
+
if t == "step.started":
|
|
94
|
+
self.rows.append(Row("step", ev["node"]))
|
|
95
|
+
elif t == "step.finished":
|
|
96
|
+
for row in reversed(self.rows):
|
|
97
|
+
if row.kind == "step" and row.label == ev["node"] and row.status == "running":
|
|
98
|
+
row.status, row.ms = "ok", ev.get("ms")
|
|
99
|
+
break
|
|
100
|
+
elif t == "model.called":
|
|
101
|
+
self.rows.append(Row("model", ev.get("model") or "model", ms=ev.get("ms"), status="ok"))
|
|
102
|
+
elif t == "tool.started":
|
|
103
|
+
self.rows.append(Row("tool", self._name(ev["tool"]), key=ev.get("id")))
|
|
104
|
+
elif t in ("tool.called", "tool.failed"):
|
|
105
|
+
label, detail, status = self._outcome(ev)
|
|
106
|
+
row = self._row(ev.get("id"))
|
|
107
|
+
if row is None:
|
|
108
|
+
self.rows.append(Row("tool", label, detail, ev.get("ms"), status))
|
|
109
|
+
else:
|
|
110
|
+
row.label, row.detail, row.ms, row.status = label, detail, ev.get("ms"), status
|
|
111
|
+
elif t in ("run.finished", "run.failed"):
|
|
112
|
+
self.outcome = ev
|
|
113
|
+
for row in self.rows:
|
|
114
|
+
if row.status == "running":
|
|
115
|
+
row.status = "ok" if t == "run.finished" else "failed"
|
|
116
|
+
|
|
117
|
+
def _name(self, exposed: str) -> str:
|
|
118
|
+
return self.gravity_name.get(exposed, exposed)
|
|
119
|
+
|
|
120
|
+
def _row(self, key: str | None) -> Row | None:
|
|
121
|
+
return next((r for r in self.rows if key and r.key == key), None)
|
|
122
|
+
|
|
123
|
+
def _outcome(self, ev: dict) -> tuple[str, str, str]:
|
|
124
|
+
"""(label, detail, status) for a finished call; a refusal may arrive as an exception or as output."""
|
|
125
|
+
name = self._name(ev["tool"])
|
|
126
|
+
text = ev.get("error") if ev["type"] == "tool.failed" else ev.get("output", "")
|
|
127
|
+
text = text or ""
|
|
128
|
+
if "TOOL_NOT_DECLARED" in text:
|
|
129
|
+
# Never declared, so not in our name map; the refusal names it.
|
|
130
|
+
named = re.search(r"'([a-z0-9_.]+)' is not in", text)
|
|
131
|
+
return (named.group(1) if named else name, "refused — not in permissions.tools", "refused")
|
|
132
|
+
if "APPROVAL_REJECTED" in text:
|
|
133
|
+
return name, "not approved — did not run", "refused"
|
|
134
|
+
if "APPROVAL_TIMEOUT" in text:
|
|
135
|
+
return name, "approval timed out — did not run", "refused"
|
|
136
|
+
failed = re.search(r"\b(TOOL_UNAVAILABLE|NEEDS_RECONNECT|RUN_NOT_ACTIVE|UNAUTHORIZED)\b", text)
|
|
137
|
+
if failed:
|
|
138
|
+
# A gateway error returned as output, not raised: still a failure.
|
|
139
|
+
return name, text[failed.start():][:80], "failed"
|
|
140
|
+
if ev["type"] == "tool.failed":
|
|
141
|
+
return name, text[:80], "failed"
|
|
142
|
+
detail = _tool_detail(text)
|
|
143
|
+
return name, detail, "failed" if detail.startswith("error:") else "ok"
|
|
144
|
+
|
|
145
|
+
def awaiting(self, key: str | None, summary: str) -> None:
|
|
146
|
+
"""A call the gateway is holding for approval."""
|
|
147
|
+
row = self._row(key)
|
|
148
|
+
if row is not None:
|
|
149
|
+
row.status, row.detail = "awaiting", summary
|
|
150
|
+
|
|
151
|
+
def continue_below(self, key: str | None) -> None:
|
|
152
|
+
"""After a prompt, redraw from the paused step, not the whole run."""
|
|
153
|
+
row = self._row(key)
|
|
154
|
+
at = self.rows.index(row) if row is not None else len(self.rows)
|
|
155
|
+
steps = [i for i, r in enumerate(self.rows[:at]) if r.kind == "step"]
|
|
156
|
+
self.shown_from = steps[-1] if steps else at
|
|
157
|
+
|
|
158
|
+
def approved(self, key: str | None) -> None:
|
|
159
|
+
row = self._row(key)
|
|
160
|
+
if row is not None:
|
|
161
|
+
row.status, row.detail = "running", "approved — running"
|
|
162
|
+
|
|
163
|
+
# drawing
|
|
164
|
+
|
|
165
|
+
def render(self) -> Table:
|
|
166
|
+
grid = Table.grid(padding=(0, 2))
|
|
167
|
+
grid.add_column(width=4)
|
|
168
|
+
grid.add_column(min_width=24)
|
|
169
|
+
grid.add_column(min_width=18, style="cyan")
|
|
170
|
+
grid.add_column(justify="right", style="dim")
|
|
171
|
+
for row in self.rows[self.shown_from:]:
|
|
172
|
+
indent = "" if row.kind == "step" else " "
|
|
173
|
+
if row.kind == "step":
|
|
174
|
+
icon = Spinner("dots", style="yellow") if row.status == "running" else Text("●", "bold blue")
|
|
175
|
+
label = Text(row.label, "bold")
|
|
176
|
+
elif row.kind == "model":
|
|
177
|
+
icon, label = Text(indent + "◆", "magenta"), Text(row.label, "magenta")
|
|
178
|
+
else:
|
|
179
|
+
icon = {
|
|
180
|
+
"running": Spinner("dots", style="yellow"),
|
|
181
|
+
"awaiting": Text(indent + "⏸", "bold yellow"),
|
|
182
|
+
"ok": Text(indent + "✓", "bold green"),
|
|
183
|
+
"refused": Text(indent + "✗", "bold red"),
|
|
184
|
+
"failed": Text(indent + "✗", "bold red"),
|
|
185
|
+
}[row.status]
|
|
186
|
+
label = Text(row.label, "red" if row.status in ("refused", "failed") else "")
|
|
187
|
+
detail = Text(
|
|
188
|
+
row.detail,
|
|
189
|
+
{"refused": "red", "failed": "red", "awaiting": "yellow"}.get(row.status, "cyan"),
|
|
190
|
+
)
|
|
191
|
+
grid.add_row(icon, label, detail, _seconds(row.ms))
|
|
192
|
+
return grid
|
|
193
|
+
|
|
194
|
+
def live(self) -> Live:
|
|
195
|
+
return Live(self.render(), console=self.console, refresh_per_second=12, get_renderable=self.render)
|
|
196
|
+
|
|
197
|
+
def finish(self) -> int:
|
|
198
|
+
"""Print the result card and the final line; returns the exit code."""
|
|
199
|
+
out = self.outcome or {"type": "run.failed", "error": "the runner exited without a result"}
|
|
200
|
+
# The live view ends mid-line; the first newline closes it.
|
|
201
|
+
self.console.line(2)
|
|
202
|
+
if out["type"] == "run.failed":
|
|
203
|
+
where = "loading the agent" if out.get("stage") == "load" else "the run"
|
|
204
|
+
self.console.print(
|
|
205
|
+
Panel(Text(out.get("error", ""), "red"), title=f"[red]failed in {where}", border_style="red")
|
|
206
|
+
)
|
|
207
|
+
return 1
|
|
208
|
+
if not out.get("has_result"):
|
|
209
|
+
self.console.print(
|
|
210
|
+
" [yellow]⚠[/yellow] the final state has no [bold]result[/bold] — a consumer would "
|
|
211
|
+
"get nothing (AGENT_STANDARD.md §3)"
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
self.console.print(Panel(_render_value(out.get("result")), title="result", border_style="green", expand=False))
|
|
215
|
+
self.console.print(f" [green]✔[/green] done in {_seconds(out.get('ms'))}\n")
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _render_value(value: Any):
|
|
220
|
+
"""A result as prose, a key/value list, or a table of records."""
|
|
221
|
+
if isinstance(value, str) or value is None:
|
|
222
|
+
return Text(str(value or ""))
|
|
223
|
+
if isinstance(value, list):
|
|
224
|
+
return _render_list(value)
|
|
225
|
+
if not isinstance(value, dict):
|
|
226
|
+
return Text(json.dumps(value, default=str))
|
|
227
|
+
parts = []
|
|
228
|
+
for key, v in value.items():
|
|
229
|
+
if key == "source":
|
|
230
|
+
continue
|
|
231
|
+
if isinstance(v, list) and v:
|
|
232
|
+
parts += [Text(key, "bold"), _render_list(v)]
|
|
233
|
+
elif isinstance(v, dict):
|
|
234
|
+
parts.append(Text.assemble((f"{key} ", "bold"), json.dumps(v, default=str)[:200]))
|
|
235
|
+
elif v not in (None, "", []):
|
|
236
|
+
parts.append(Text.assemble((f"{key} ", "bold"), str(v)))
|
|
237
|
+
return Group(*parts)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _render_list(items: list):
|
|
241
|
+
if all(isinstance(i, dict) for i in items):
|
|
242
|
+
columns: list[str] = []
|
|
243
|
+
for item in items:
|
|
244
|
+
for k, v in item.items():
|
|
245
|
+
# URLs don't fit a terminal table; the id beside them does.
|
|
246
|
+
if k not in columns and k != "url" and not isinstance(v, dict | list):
|
|
247
|
+
columns.append(k)
|
|
248
|
+
table = Table(box=None, pad_edge=False, show_edge=False, header_style="dim")
|
|
249
|
+
for c in columns[:6]:
|
|
250
|
+
table.add_column(c)
|
|
251
|
+
for item in items:
|
|
252
|
+
table.add_row(*(str(item.get(c) if item.get(c) is not None else "") for c in columns[:6]))
|
|
253
|
+
return table
|
|
254
|
+
return Text("\n".join(f"• {i}" for i in items))
|