levain-sdk 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 (62) hide show
  1. levain_sdk/__init__.py +21 -0
  2. levain_sdk/_mcp.py +30 -0
  3. levain_sdk/agents_tools/__init__.py +20 -0
  4. levain_sdk/agents_tools/mcp.py +119 -0
  5. levain_sdk/app_data/__init__.py +1 -0
  6. levain_sdk/app_data/mcp.py +225 -0
  7. levain_sdk/app_data/query.py +339 -0
  8. levain_sdk/artifacts/__init__.py +21 -0
  9. levain_sdk/artifacts/_publish_raw_source.py +86 -0
  10. levain_sdk/artifacts/_staging.py +194 -0
  11. levain_sdk/artifacts/mcp.py +302 -0
  12. levain_sdk/catalog.py +21 -0
  13. levain_sdk/claude.py +1238 -0
  14. levain_sdk/crawler/__init__.py +971 -0
  15. levain_sdk/errors.py +53 -0
  16. levain_sdk/formatting.py +40 -0
  17. levain_sdk/git.py +608 -0
  18. levain_sdk/github_tools/__init__.py +5 -0
  19. levain_sdk/github_tools/mcp.py +628 -0
  20. levain_sdk/handlers.py +198 -0
  21. levain_sdk/hermes.py +590 -0
  22. levain_sdk/hermes_driver.py +231 -0
  23. levain_sdk/hermes_memory.py +169 -0
  24. levain_sdk/image_tools/__init__.py +1 -0
  25. levain_sdk/image_tools/mcp.py +85 -0
  26. levain_sdk/integrations.py +283 -0
  27. levain_sdk/mcp_registry.py +284 -0
  28. levain_sdk/mcp_stdio.py +103 -0
  29. levain_sdk/memory.py +48 -0
  30. levain_sdk/memory_tools/__init__.py +13 -0
  31. levain_sdk/memory_tools/mcp.py +403 -0
  32. levain_sdk/messages.py +151 -0
  33. levain_sdk/models.py +645 -0
  34. levain_sdk/models_catalog.md +205 -0
  35. levain_sdk/platform_actions/__init__.py +39 -0
  36. levain_sdk/platform_actions/google_ads.py +235 -0
  37. levain_sdk/platform_actions/meta_ads.py +102 -0
  38. levain_sdk/platform_actions/shopify.py +147 -0
  39. levain_sdk/platform_actions/stripe.py +137 -0
  40. levain_sdk/progress_tools/__init__.py +1 -0
  41. levain_sdk/progress_tools/mcp.py +81 -0
  42. levain_sdk/prompts.py +31 -0
  43. levain_sdk/py.typed +0 -0
  44. levain_sdk/reducers.py +69 -0
  45. levain_sdk/review/__init__.py +0 -0
  46. levain_sdk/review/github.py +730 -0
  47. levain_sdk/review/mcp.py +602 -0
  48. levain_sdk/review/store.py +905 -0
  49. levain_sdk/sandbox_api.py +69 -0
  50. levain_sdk/sessions_tools/__init__.py +0 -0
  51. levain_sdk/sessions_tools/mcp.py +88 -0
  52. levain_sdk/shell.py +156 -0
  53. levain_sdk/slack/__init__.py +1 -0
  54. levain_sdk/slack/mcp.py +274 -0
  55. levain_sdk/wiki/__init__.py +15 -0
  56. levain_sdk/wiki/index.py +366 -0
  57. levain_sdk/wiki/mcp.py +424 -0
  58. levain_sdk/wiki/scoring.py +173 -0
  59. levain_sdk-0.1.0.dist-info/METADATA +64 -0
  60. levain_sdk-0.1.0.dist-info/RECORD +62 -0
  61. levain_sdk-0.1.0.dist-info/WHEEL +4 -0
  62. levain_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
levain_sdk/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Levain Recipe SDK — helpers for recipe node functions.
2
+
3
+ Zero dependency on levain. Levain depends on the SDK,
4
+ not the other way around.
5
+ """
6
+
7
+ from .errors import (
8
+ AgentError,
9
+ GitHubAPIError,
10
+ KneadError,
11
+ OperationTimeoutError,
12
+ )
13
+ from .shell import run_checks
14
+
15
+ __all__ = [
16
+ "AgentError",
17
+ "GitHubAPIError",
18
+ "KneadError",
19
+ "OperationTimeoutError",
20
+ "run_checks",
21
+ ]
levain_sdk/_mcp.py ADDED
@@ -0,0 +1,30 @@
1
+ """Shared helper for FastMCP → ClaudeAgentOptions config.
2
+
3
+ Every platform MCP server (``levain-build``, ``knead-review``)
4
+ needs the same dance: pull the FastMCP
5
+ instance's private ``_mcp_server`` attribute and wrap it in the
6
+ ``McpSdkServerConfig`` shape the SDK expects. Kept central so a
7
+ FastMCP API change is patched in one place, not three.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+
15
+ def sdk_mcp_config(mcp_instance: Any, server_name: str) -> dict[str, Any]:
16
+ """Return an in-process MCP server config for ``ClaudeAgentOptions``.
17
+
18
+ ``_mcp_server`` is a private attribute of FastMCP; re-verify
19
+ it exists when bumping the ``fastmcp`` pin (tested against
20
+ 3.1.1). A missing attribute means the FastMCP internals
21
+ moved and the caller needs to track the new shape.
22
+ """
23
+ server = getattr(mcp_instance, "_mcp_server", None)
24
+ if server is None or not hasattr(server, "run"):
25
+ raise RuntimeError(
26
+ f"FastMCP internal API changed — _mcp_server no longer "
27
+ f"exists or changed type (server={server_name!r}). Pin "
28
+ f"fastmcp to the version this was tested against."
29
+ )
30
+ return {"type": "sdk", "name": server_name, "instance": server}
@@ -0,0 +1,20 @@
1
+ """Agent-to-agent calling tools (the ``levain-agents`` MCP).
2
+
3
+ The ``call_agent`` tool lets an agent delegate a task to one of the
4
+ workspace's other agents. The tool only *signals* the request; the
5
+ recipe's worker-side ``CallAgent`` node performs the dispatch via
6
+ the harness A2A machinery (:mod:`levain.agent.harness.a2a`), so the
7
+ untrusted sandbox never reaches dispatch directly.
8
+
9
+ Lives in the customer SDK (moved from ``platform_sdk`` so it works on
10
+ the customer image — the managed Levain assistant is the first
11
+ consumer there, and A2A is a customer-facing capability anyway). The
12
+ worker still gates who may actually be called: dispatch authorizes
13
+ from the caller run's own context, never from the sandbox's claims.
14
+ """
15
+
16
+ # Env var the harness injects per turn with the JSON list of agents
17
+ # the Levain agent may call (base64). Read by the recipe's Respond
18
+ # node to render the choices into the prompt — discovery is
19
+ # prompt-side, not a tool round-trip.
20
+ ENV_CALLABLE_AGENTS = "LEVAIN_CALLABLE_AGENTS_B64"
@@ -0,0 +1,119 @@
1
+ """FastMCP server exposing the agent-to-agent ``call_agent`` tool.
2
+
3
+ Runs in-process inside the sandbox via stdio. Lets an agent
4
+ delegate a task to one of the workspace's other agents.
5
+
6
+ This is a *signal* tool, not an RPC client: calling it only records
7
+ the requested ``(agent, request)`` in a signal file. The Levain
8
+ recipe's ``Respond`` node reads that signal after the agent's SDK
9
+ session returns (:func:`consume_call`), routes the graph to a
10
+ ``CallAgent`` node, and that node — running in the **trusted
11
+ worker** — performs the dispatch via the harness A2A machinery and
12
+ feeds the result back into the next ``Respond`` turn. The untrusted
13
+ sandbox never reaches dispatch directly and never supplies the
14
+ caller's identity; the worker authorizes from the caller run's own
15
+ context. The tool call itself only records a module-level signal
16
+ that the node wrapper consumes after the agent returns.
17
+
18
+ Discovery is prompt-side: the worker injects the callable-agents
19
+ list into the ``Respond`` prompt, so there's no ``list_agents``
20
+ tool here.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from fastmcp import FastMCP
32
+
33
+ from levain_sdk._mcp import sdk_mcp_config
34
+
35
+ log = logging.getLogger("knead")
36
+
37
+ mcp = FastMCP("levain-agents")
38
+
39
+
40
+ @dataclass
41
+ class _AgentCall:
42
+ """A delegation the Levain agent requested this turn."""
43
+
44
+ slug: str
45
+ request: str
46
+
47
+
48
+ # File-backed signal, written by ``call_agent`` and reset by ``init``
49
+ # on each Respond dispatch so a stale request can't leak across
50
+ # turns. Single-slot: the tool's contract is "call it, then wrap up",
51
+ # so at most one delegation per session (the recipe loops for more).
52
+ # A file rather than a module global because the server doesn't
53
+ # always share a process with the node that reads the signal: the
54
+ # Claude engine runs it in-process, but the Hermes engine runs it as
55
+ # a stdio subprocess (``levain_sdk.mcp_stdio``). Same pattern as the
56
+ # ``levain-memory`` queue.
57
+ _SIGNAL_PATH = Path("/tmp/levain-agents/call.json")
58
+
59
+
60
+ def init(repo_root: str = "", branch: str = "", iteration: int = 0) -> None:
61
+ """Reset the pending-call signal for this Respond dispatch."""
62
+ del repo_root, branch, iteration
63
+ _SIGNAL_PATH.unlink(missing_ok=True)
64
+
65
+
66
+ def consume_call() -> _AgentCall | None:
67
+ """Return the captured delegation, if any, and clear it."""
68
+ try:
69
+ raw = _SIGNAL_PATH.read_text(encoding="utf-8")
70
+ except FileNotFoundError:
71
+ return None
72
+ _SIGNAL_PATH.unlink(missing_ok=True)
73
+ try:
74
+ record = json.loads(raw)
75
+ except ValueError:
76
+ log.warning("CALL_AGENT unparseable signal file; ignoring")
77
+ return None
78
+ return _AgentCall(
79
+ slug=str(record.get("slug") or ""),
80
+ request=str(record.get("request") or ""),
81
+ )
82
+
83
+
84
+ @mcp.tool
85
+ async def call_agent(agent: str, request: str) -> dict[str, Any]:
86
+ """Hand a task to one of the workspace's other agents.
87
+
88
+ Use the ``agent`` slug from the list of available agents in your
89
+ instructions. ``request`` is what you want that agent to do, in
90
+ plain language — it's delivered as the agent's prompt.
91
+
92
+ Calling this **ends your current turn**: the agent runs and you
93
+ are re-invoked with its result, which you then use to answer.
94
+ The agent may take several minutes depending on the task, so
95
+ close your turn with a short note telling the user what you
96
+ handed off and that the answer is coming — don't also answer
97
+ the question yourself.
98
+ Call it only when an available agent clearly fits the task; do
99
+ not invent a slug.
100
+ """
101
+ slug = (agent or "").strip()
102
+ record = {"slug": slug, "request": (request or "").strip()}
103
+ _SIGNAL_PATH.parent.mkdir(parents=True, exist_ok=True)
104
+ _SIGNAL_PATH.write_text(json.dumps(record), encoding="utf-8")
105
+ log.info("CALL_AGENT delegation requested (agent=%r)", slug)
106
+ return {
107
+ "ok": True,
108
+ "message": (
109
+ f"Delegating to {slug!r}. Stop here — you'll be re-invoked "
110
+ "with the agent's result, which can take several minutes. "
111
+ "End your turn with a one-line heads-up that you've handed "
112
+ "this off; don't answer the question yourself."
113
+ ),
114
+ }
115
+
116
+
117
+ def create_agents_mcp_server() -> dict[str, Any]:
118
+ """Return an in-process MCP server config for ClaudeAgentOptions."""
119
+ return sdk_mcp_config(mcp, "levain-agents")
@@ -0,0 +1 @@
1
+ """Synced platform data — workspace-scoped query tools for agents."""
@@ -0,0 +1,225 @@
1
+ """FastMCP server exposing synced platform-app data to agents.
2
+
3
+ A recipe opts in via ``@mcp_servers ["levain-app-data"]``. The harness injects,
4
+ on the attached platform-app integrations' ``metadata.app_data``, a **direct
5
+ ClickHouse connection** for this run: the workspace's readonly CH account
6
+ (``agent_user_<ws>``) plus a short-lived workspace JWT used as its password.
7
+ ClickHouse validates that JWT via its ``levain_agent_auth`` callback and applies
8
+ the account's row policy, so queries are workspace-scoped **at the database** —
9
+ the agent sees only its own workspace's rows, over a read-only, capped account.
10
+
11
+ Tables cover the connected apps: Stripe (charges, subscriptions, invoices),
12
+ Shopify (orders, checkouts, products), app events, and daily ads campaign
13
+ metrics. ``list_app_tables`` returns the authoritative schema.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ import os
22
+ from typing import Any
23
+
24
+ from fastmcp import FastMCP
25
+
26
+ from .._mcp import sdk_mcp_config
27
+ from ..integrations import load_attached
28
+ from .query import (
29
+ AppDataUnavailable,
30
+ QueryError,
31
+ build_sql,
32
+ probe_coverage,
33
+ run_query,
34
+ )
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+ mcp = FastMCP("levain-app-data")
39
+
40
+ # ClickHouse connection + shipped table manifest for this run.
41
+ _conn: dict[str, Any] = {}
42
+ _tables: dict[str, Any] = {}
43
+ # Row count + time span per table, probed once per run (see ``_coverage``).
44
+ _coverage_cache: dict[str, dict[str, Any]] | None = None
45
+ _coverage_lock = asyncio.Lock()
46
+
47
+
48
+ def init(
49
+ repo_root: str,
50
+ branch: str = "",
51
+ iteration: int = 0,
52
+ ) -> None:
53
+ """Locate the ClickHouse connection from attached integrations."""
54
+ global _conn, _tables, _coverage_cache
55
+ _conn = {}
56
+ _tables = {}
57
+ _coverage_cache = None
58
+ for integ in load_attached():
59
+ app_data = (integ.get("metadata") or {}).get("app_data") or {}
60
+ if app_data.get("ch_host") and app_data.get("ch_token"):
61
+ _conn = {
62
+ "ch_host": app_data["ch_host"],
63
+ "ch_port": app_data.get("ch_port", 8123),
64
+ "ch_database": app_data.get("ch_database", "default"),
65
+ "ch_user": app_data["ch_user"],
66
+ "ch_token": app_data["ch_token"],
67
+ "ch_distributed": bool(app_data.get("ch_distributed")),
68
+ }
69
+ _tables = app_data.get("tables") or {}
70
+ return
71
+ # Fallback for platform recipes / tests that inject a connection directly.
72
+ host = os.environ.get("LEVAIN_APP_DATA_CH_HOST", "")
73
+ if host:
74
+ _conn = {
75
+ "ch_host": host,
76
+ "ch_port": int(os.environ.get("LEVAIN_APP_DATA_CH_PORT", "8123")),
77
+ "ch_database": os.environ.get("LEVAIN_APP_DATA_CH_DB", "default"),
78
+ "ch_user": os.environ.get("LEVAIN_APP_DATA_CH_USER", ""),
79
+ "ch_token": os.environ.get("LEVAIN_APP_DATA_CH_TOKEN", ""),
80
+ "ch_distributed": os.environ.get("LEVAIN_APP_DATA_CH_DIST") == "1",
81
+ }
82
+ raw = os.environ.get("LEVAIN_APP_DATA_TABLES", "")
83
+ _tables = json.loads(raw) if raw else {}
84
+
85
+
86
+ def _require_conn() -> None:
87
+ if not _conn:
88
+ raise RuntimeError(
89
+ "app-data is not available on this run — connect a platform "
90
+ "app (Stripe, Shopify, Google Ads, Meta Ads) and attach it to "
91
+ "this agent"
92
+ )
93
+
94
+
95
+ async def _coverage() -> dict[str, dict[str, Any]]:
96
+ """Cached per-table row count and time span for this run."""
97
+ global _coverage_cache
98
+ async with _coverage_lock:
99
+ if _coverage_cache is None:
100
+ _coverage_cache = await probe_coverage(_conn, _tables)
101
+ return _coverage_cache
102
+
103
+
104
+ @mcp.tool()
105
+ async def list_app_tables() -> dict[str, Any]:
106
+ """Describe the queryable synced-data tables, columns, and coverage.
107
+
108
+ Call this first to learn table names, columns, and each table's time
109
+ column before using ``query_app_data``. Each table also carries its
110
+ ``row_count`` and the ``first_at``/``last_at`` timestamps it actually
111
+ holds — pick a time window inside that span instead of guessing one, and
112
+ skip tables whose ``row_count`` is 0.
113
+ """
114
+ _require_conn()
115
+ coverage = await _coverage()
116
+ tables = {
117
+ name: {**spec, **coverage[name]} if name in coverage else spec
118
+ for name, spec in _tables.items()
119
+ }
120
+ return {"tables": tables}
121
+
122
+
123
+ @mcp.tool()
124
+ async def query_app_data(
125
+ table: str,
126
+ columns: list[str] | None = None,
127
+ filters: list[dict[str, Any]] | None = None,
128
+ aggregates: list[dict[str, Any]] | None = None,
129
+ group_by: list[str] | None = None,
130
+ order_by: list[dict[str, Any]] | None = None,
131
+ since: str | None = None,
132
+ until: str | None = None,
133
+ limit: int = 100,
134
+ ) -> dict[str, Any]:
135
+ """Query a synced-data table (workspace-scoped, read-only).
136
+
137
+ Choose ``since``/``until`` from the coverage ``list_app_tables`` reports
138
+ for the table, not from an assumed range. A result with ``row_count`` 0
139
+ carries a ``hint`` with the table's real row count and time span — read
140
+ it and correct the query, rather than retrying another guess.
141
+
142
+ Args:
143
+ table: one of the tables from ``list_app_tables``.
144
+ columns: columns to select (omit for all, or rely on aggregates).
145
+ filters: ``[{"column": ..., "op": "="|"!="|">"|">="|"<"|"<="|"in"|
146
+ "like", "value": ...}]``.
147
+ aggregates: ``[{"fn": "count"|"sum"|"avg"|"min"|"max", "column": ...,
148
+ "alias": ...}]`` — combine with ``group_by`` for rollups.
149
+ group_by: grouping columns. When set, ``columns`` may list only
150
+ group-by keys — bare non-grouped columns are dropped (ClickHouse
151
+ requires every selected column to be a group key or aggregated).
152
+ order_by: ``[{"column": ..., "desc": true}]``. When ``group_by`` is
153
+ set, each column must be a group-by key or an aggregate alias.
154
+ since/until: ISO bounds on the table's time column.
155
+ limit: max rows (≤1000).
156
+
157
+ Example — failed charge volume by week:
158
+ ``query_app_data(table="stripe_charges",
159
+ filters=[{"column": "status", "value": "failed"}],
160
+ aggregates=[{"fn": "count", "alias": "n"},
161
+ {"fn": "sum", "column": "amount", "alias": "total"}],
162
+ group_by=["currency"], since="2026-05-01")``
163
+ """
164
+ _require_conn()
165
+ spec = _tables.get(table)
166
+ if spec is None:
167
+ return {"error": f"unknown table {table!r}; one of {sorted(_tables)}"}
168
+ resolved = f"{table}_distributed" if _conn["ch_distributed"] else table
169
+ try:
170
+ sql, params = build_sql(
171
+ spec,
172
+ resolved,
173
+ columns=columns or [],
174
+ filters=filters or [],
175
+ aggregates=aggregates or [],
176
+ group_by=group_by or [],
177
+ order_by=order_by or [],
178
+ since=since,
179
+ until=until,
180
+ limit=limit,
181
+ )
182
+ except QueryError as e:
183
+ # Ship the table's schema with the rejection so the corrected retry
184
+ # needs no extra ``list_app_tables`` round trip.
185
+ return {
186
+ "error": str(e),
187
+ "table": table,
188
+ "time_column": spec["time_column"],
189
+ "columns": spec["columns"],
190
+ }
191
+ try:
192
+ rows = await run_query(_conn, sql, params)
193
+ except (AppDataUnavailable, QueryError) as e:
194
+ return {"error": str(e)}
195
+ if rows:
196
+ return {"rows": rows, "row_count": len(rows)}
197
+ # Empty means the filters/window missed, not that the table is empty —
198
+ # ship what the table actually holds so the retry is corrected in one
199
+ # step instead of guessing another window.
200
+ result: dict[str, Any] = {"rows": rows, "row_count": 0}
201
+ covered = (await _coverage()).get(table)
202
+ if covered:
203
+ if covered.get("row_count"):
204
+ message = (
205
+ "no rows matched — the filters or time window are off, not "
206
+ "the table. Correct them from this coverage rather than "
207
+ "retrying another guess"
208
+ )
209
+ else:
210
+ message = (
211
+ "this table has nothing synced yet — no filter or window "
212
+ "change will return rows; try a different table"
213
+ )
214
+ result["hint"] = {
215
+ "message": message,
216
+ "table": table,
217
+ "time_column": spec["time_column"],
218
+ **covered,
219
+ }
220
+ return result
221
+
222
+
223
+ def create_app_data_mcp_server() -> dict[str, Any]:
224
+ """Registry factory — see ``levain_sdk.mcp_registry``."""
225
+ return sdk_mcp_config(mcp, "levain-app-data")