keystone-agent-sdk 0.3.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.
@@ -0,0 +1,47 @@
1
+ """Keystone AI — pro-code agent SDK (``keystone.agent_sdk``).
2
+
3
+ The developer's interface to the platform for **authoring** agents (FR-04 / FDP-3072).
4
+ M1 (walking skeleton) surface:
5
+
6
+ * **Authoring** — build the graph with **LangGraph directly** (``from langgraph.graph import
7
+ StateGraph``) + tools with ``langchain_core`` (``from langchain_core.tools import tool``). The
8
+ SDK does NOT wrap them, so an existing LangGraph agent runs here **unchanged**.
9
+ * **Manifest** — :class:`AgentManifest` (the ``agent.yaml`` schema, owned by the SDK
10
+ and shared by validate / run / deploy) + :func:`load_manifest`.
11
+ * **Validation** — :func:`validate` (manifest + entrypoint importability).
12
+ * **HITL** — :func:`request_approval` (stub; lands in M3 — FDP-3078).
13
+
14
+ Building-block clients live in the ``keystone.agent_sdk.clients`` subpackage
15
+ (``from keystone.agent_sdk.clients import GatewayClient, RAGClient`` — FDP-3118) — kept OFF the
16
+ top-level import so authoring-only agents don't pull the HTTP / request-context deps. The CLI
17
+ (``keystone agent run/validate``) is the separate ``keystone-cli`` package (FDP-3120).
18
+
19
+ Usage::
20
+
21
+ from langgraph.graph import StateGraph # graph = LangGraph, the SDK doesn't wrap it
22
+ from keystone.agent_sdk import AgentManifest, validate
23
+ from keystone.agent_sdk.clients import GatewayClient, RAGClient
24
+ """
25
+
26
+ from .durable import durable_task, idempotency_key
27
+ from .hitl import request_approval
28
+ from .loader import GraphLoadError, load_graph
29
+ from .manifest import AgentManifest, Dependencies, HitlSpec, Identity, ResourceSpec, SecretRef, load_manifest
30
+ from .validate import ValidationResult, validate
31
+
32
+ __all__ = [
33
+ "AgentManifest",
34
+ "Dependencies",
35
+ "GraphLoadError",
36
+ "HitlSpec",
37
+ "Identity",
38
+ "ResourceSpec",
39
+ "SecretRef",
40
+ "ValidationResult",
41
+ "durable_task",
42
+ "idempotency_key",
43
+ "load_graph",
44
+ "load_manifest",
45
+ "request_approval",
46
+ "validate",
47
+ ]
@@ -0,0 +1,183 @@
1
+ """Package an agent project into a deterministic ``bundle.tar.gz`` + SHA-256 (deploy, FDP-3169).
2
+
3
+ Backs ``keystone agent deploy``: tar the project (source + agent.yaml + uv.lock) honouring ``.gitignore``,
4
+ skipping VCS/venv/cache junk. The SHA-256 (over the UNCOMPRESSED, normalized tar) is a stable idempotency
5
+ key — the same source produces the same hash, so a re-deploy dedupes to the existing version (cf. RAG file
6
+ upload). stdlib-only (no new dep); NOT re-exported at package top-level (deploy-time, not authoring).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fnmatch
12
+ import gzip
13
+ import hashlib
14
+ import io
15
+ import re
16
+ import tarfile
17
+ from dataclasses import dataclass
18
+ from dataclasses import field as dataclass_field
19
+ from pathlib import Path
20
+
21
+ # Always excluded regardless of .gitignore — VCS, venvs, caches, editor/OS cruft, build output.
22
+ _ALWAYS_IGNORE_DIRS = frozenset(
23
+ {
24
+ ".git",
25
+ ".venv",
26
+ "venv",
27
+ "env",
28
+ "__pycache__",
29
+ ".mypy_cache",
30
+ ".ruff_cache",
31
+ ".pytest_cache",
32
+ "node_modules",
33
+ ".idea",
34
+ ".vscode",
35
+ "dist",
36
+ "build",
37
+ ".keystone",
38
+ }
39
+ )
40
+ # `.env*` is excluded UNCONDITIONALLY, not left to .gitignore. It is the file the dev is told to put
41
+ # their platform API key in (`RT_AGENT_API_KEY`, access-policy.md §4.2), and a bundle is not a private
42
+ # thing: it lands in S3 and is baked into the agent's image in ECR — where retire/delete cleanup is
43
+ # still OFF (technical-debt.md §7), so a copy outlives a revoked key. Nothing needs it there either:
44
+ # a pod gets its env from the operator (ConfigMap/Secret), and `.env` only exists because
45
+ # `RuntimeSettings` reads `env_file=".env"` for LOCAL runs. Relying on the project's .gitignore would
46
+ # make this depend on a file the dev edits — and the scaffold's .gitignore didn't list `.env` at all
47
+ # until this change, so every project generated before it would have shipped the key.
48
+ _ALWAYS_IGNORE_GLOBS = ("*.pyc", "*.pyo", "*.egg-info", ".DS_Store", "*.log", ".env", ".env.*")
49
+
50
+ # The platform API key format (`ak_<env>_<short_id>.<secret>`, agent-identity reference / platform
51
+ # `api_keys`). Distinctive enough to match on directly, which is the point: this is a targeted check
52
+ # for OUR credential, not a general-purpose secret scanner (detect-secrets already runs pre-commit).
53
+ # Catches the key wherever it was pasted — `agent.yaml`'s `config:`, source, a README, or a `.env`
54
+ # variant under a name the glob above doesn't cover.
55
+ _API_KEY_RE = re.compile(r"\bak_[a-z0-9]{2,16}_[A-Za-z0-9]{4,}\.[A-Za-z0-9_-]{16,}")
56
+
57
+ # Cap per-file scanning: a bundle may legitimately carry a model or fixture, and reading a 500 MB
58
+ # file to look for a 60-char token is not worth it. Anything that fails to decode as UTF-8 is binary
59
+ # and skipped for the same reason.
60
+ _SCAN_MAX_BYTES = 1 << 20 # 1 MiB
61
+
62
+
63
+ class SecretInBundleError(Exception):
64
+ """A bundled file contains a platform API key. Carries ``file``/``line`` — never the value.
65
+
66
+ The value is deliberately NOT in the message: this surfaces in terminal scrollback and CI job
67
+ logs, so echoing the credential there would leak it a second time in the act of reporting it.
68
+ """
69
+
70
+ def __init__(self, file: str, line: int) -> None:
71
+ super().__init__(
72
+ f"{file}:{line} contains what looks like a platform API key (ak_<env>_<id>.<secret>). "
73
+ "Refusing to package it: a bundle is uploaded to S3 and baked into the agent image in "
74
+ "ECR, so the key would outlive being revoked. Put it in .env (never bundled) and let the "
75
+ "operator inject it, or reference it from the portal — do not commit it."
76
+ )
77
+ self.file = file
78
+ self.line = line
79
+
80
+
81
+ @dataclass
82
+ class BundleResult:
83
+ """A packaged agent project. ``data`` = gzip bytes to upload; ``sha256`` = idempotency key.
84
+
85
+ ``skipped_env`` names the env files left out (see ``_ALWAYS_IGNORE_GLOBS``). Reported so the
86
+ caller can SAY it: a dev who put config in ``.env`` and sees it missing at runtime would
87
+ otherwise have no way to know the packager dropped it on purpose.
88
+ """
89
+
90
+ data: bytes
91
+ sha256: str
92
+ file_count: int
93
+ skipped_env: list[str] = dataclass_field(default_factory=list)
94
+
95
+
96
+ def _gitignore_patterns(root: Path) -> list[str]:
97
+ gi = root / ".gitignore"
98
+ if not gi.is_file():
99
+ return []
100
+ out: list[str] = []
101
+ for raw in gi.read_text(encoding="utf-8", errors="replace").splitlines():
102
+ line = raw.strip()
103
+ if not line or line.startswith("#") or line.startswith("!"): # negation unsupported (v1)
104
+ continue
105
+ out.append(line.rstrip("/"))
106
+ return out
107
+
108
+
109
+ def _is_ignored(rel: Path, patterns: list[str]) -> bool:
110
+ if set(rel.parts) & _ALWAYS_IGNORE_DIRS:
111
+ return True
112
+ name, rel_str = rel.name, str(rel)
113
+ if any(fnmatch.fnmatch(name, g) for g in _ALWAYS_IGNORE_GLOBS):
114
+ return True
115
+ return any(
116
+ fnmatch.fnmatch(name, p) or fnmatch.fnmatch(rel_str, p) or any(fnmatch.fnmatch(seg, p) for seg in rel.parts)
117
+ for p in patterns
118
+ )
119
+
120
+
121
+ def create_bundle(project_dir: str | Path) -> BundleResult:
122
+ """Tar+gzip ``project_dir`` (deterministic: sorted, mtime/uid/gid/mode normalized) → BundleResult.
123
+
124
+ Raises ``NotADirectoryError`` if the path isn't a directory, ``FileNotFoundError`` if there's no
125
+ ``agent.yaml`` (a deploy without a manifest is a mistake), and :class:`SecretInBundleError` if a
126
+ file that WOULD be packaged carries a platform API key."""
127
+ root = Path(project_dir).resolve()
128
+ if not root.is_dir():
129
+ raise NotADirectoryError(f"not a directory: {root}")
130
+ if not (root / "agent.yaml").is_file():
131
+ raise FileNotFoundError(f"no agent.yaml in {root} — not an agent project")
132
+
133
+ patterns = _gitignore_patterns(root)
134
+ tar_buf = io.BytesIO()
135
+ count = 0
136
+ skipped_env: list[str] = []
137
+ # Uncompressed tar, sorted + normalized → the hash is stable across machines/times.
138
+ with tarfile.open(fileobj=tar_buf, mode="w") as tar:
139
+ for path in sorted(p for p in root.rglob("*") if p.is_file()):
140
+ rel = path.relative_to(root)
141
+ if _is_ignored(rel, patterns):
142
+ if _is_env_file(rel):
143
+ skipped_env.append(str(rel))
144
+ continue
145
+ payload = path.read_bytes()
146
+ # Check BEFORE adding: the exception must leave no half-built tar behind, and a bundle
147
+ # that was already assembled is a bundle someone can accidentally upload.
148
+ _reject_api_key(str(rel), payload)
149
+ info = tarfile.TarInfo(name=str(rel))
150
+ info.size = len(payload)
151
+ info.mtime = 0
152
+ info.mode = 0o644
153
+ info.uid = info.gid = 0
154
+ info.uname = info.gname = ""
155
+ tar.addfile(info, io.BytesIO(payload))
156
+ count += 1
157
+
158
+ tar_bytes = tar_buf.getvalue()
159
+ sha256 = hashlib.sha256(tar_bytes).hexdigest()
160
+ data = gzip.compress(tar_bytes, mtime=0) # mtime=0 → deterministic gzip too
161
+ return BundleResult(data=data, sha256=sha256, file_count=count, skipped_env=skipped_env)
162
+
163
+
164
+ def _is_env_file(rel: Path) -> bool:
165
+ """True for the env-file shapes ``_ALWAYS_IGNORE_GLOBS`` drops (``.env``, ``.env.local``, …)."""
166
+ return rel.name == ".env" or rel.name.startswith(".env.")
167
+
168
+
169
+ def _reject_api_key(rel: str, payload: bytes) -> None:
170
+ """Raise :class:`SecretInBundleError` if ``payload`` carries a platform API key.
171
+
172
+ Binary and oversized files are skipped rather than scanned — see ``_SCAN_MAX_BYTES``. A key can
173
+ only be *pasted* as text, so decoding failure is a reliable "not it" rather than a blind spot.
174
+ """
175
+ if len(payload) > _SCAN_MAX_BYTES:
176
+ return
177
+ try:
178
+ text = payload.decode("utf-8")
179
+ except UnicodeDecodeError:
180
+ return
181
+ for lineno, line in enumerate(text.splitlines(), start=1):
182
+ if _API_KEY_RE.search(line):
183
+ raise SecretInBundleError(rel, lineno)
@@ -0,0 +1,27 @@
1
+ """Building-block clients for pro-code agents (FDP-3118).
2
+
3
+ M1 ships ``GatewayClient`` (chat/embeddings) + ``RAGClient`` (search) — the ``rag-qa`` agent's
4
+ deps. guardrails / memory / skill clients land as their stories ship. All clients auto-attach
5
+ the caller identity (from the request-context ContextVar the runtime sets per run) and decode
6
+ errors to :class:`GatewayError`.
7
+ """
8
+
9
+ from keystone.agent_sdk.clients._base import BaseClient
10
+ from keystone.agent_sdk.clients._errors import (
11
+ GatewayError,
12
+ GatewayForbiddenError,
13
+ GatewayRateLimitedError,
14
+ GatewayUpstreamError,
15
+ )
16
+ from keystone.agent_sdk.clients.gateway import GatewayClient
17
+ from keystone.agent_sdk.clients.rag import RAGClient
18
+
19
+ __all__ = [
20
+ "BaseClient",
21
+ "GatewayClient",
22
+ "GatewayError",
23
+ "GatewayForbiddenError",
24
+ "GatewayRateLimitedError",
25
+ "GatewayUpstreamError",
26
+ "RAGClient",
27
+ ]
@@ -0,0 +1,56 @@
1
+ """Shared base for the SDK building-block clients (proposal §3.5a: ContextVar identity +
2
+ typed error decode + singleton httpx).
3
+
4
+ The client attaches caller-identity headers from the request-context ContextVar that the agent
5
+ runtime sets per run (``keystone.request_context.gateway_identity_headers``) — the agent author
6
+ never sets headers. One ``httpx.AsyncClient`` per client instance (connection pooling).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import httpx
14
+ from keystone.agent_sdk.clients._errors import decode_gateway_error
15
+ from keystone.agent_sdk.tracing import inject_traceparent
16
+ from keystone.request_context import gateway_identity_headers
17
+
18
+
19
+ class BaseClient:
20
+ """Base HTTP client — identity injection + error decode. Subclasses add typed methods."""
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ base_url: str,
26
+ auth_token: str = "",
27
+ caller_service: str = "agent", # gateway X-Caller-Service vocab for an agent-runtime caller (Arch §10)
28
+ timeout_s: float = 60.0,
29
+ ) -> None:
30
+ self._base_url = base_url.rstrip("/")
31
+ self._auth_token = auth_token
32
+ self._caller_service = caller_service
33
+ self._http = httpx.AsyncClient(
34
+ limits=httpx.Limits(max_keepalive_connections=20, max_connections=40),
35
+ timeout=httpx.Timeout(timeout_s),
36
+ )
37
+
38
+ def _headers(self, operation: str) -> dict[str, str]:
39
+ headers = gateway_identity_headers(
40
+ auth_token=self._auth_token,
41
+ operation=operation,
42
+ caller_service=self._caller_service,
43
+ )
44
+ # FDP-3436: propagate the run's trace context so downstream hops (rag,
45
+ # llm-gateway) can join the trace. No active span (offline run) → no-op.
46
+ return inject_traceparent(headers)
47
+
48
+ async def _post(self, path: str, body: dict[str, Any], *, operation: str) -> dict[str, Any]:
49
+ response = await self._http.post(f"{self._base_url}{path}", json=body, headers=self._headers(operation))
50
+ if response.status_code >= 400:
51
+ raise decode_gateway_error(response)
52
+ return response.json()
53
+
54
+ async def aclose(self) -> None:
55
+ """Close the connection pool. Idempotent."""
56
+ await self._http.aclose()
@@ -0,0 +1,92 @@
1
+ """Typed errors for the SDK building-block clients.
2
+
3
+ SDK-LOCAL copy of the gateway error-decode shape (FDP-3118 scope call — `decode_gateway_error`
4
+ is NOT lifted to a shared lib this milestone; promote to ``keystone.errors`` when the
5
+ guardrails/memory/skill clients land). The envelope is the keystone-standard
6
+ ``{"error": {"code", "message", "request_id"}}`` — so the same decoder serves rag responses too
7
+ (a rag error decodes to :class:`GatewayError` carrying its ``RAG_*`` code).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ import httpx
16
+
17
+
18
+ class GatewayError(Exception):
19
+ """A building-block call returned a non-2xx envelope."""
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ code: str,
25
+ message: str,
26
+ request_id: str | None = None,
27
+ retry_after: int | None = None,
28
+ ) -> None:
29
+ super().__init__(f"[{code}] {message}")
30
+ self.code = code
31
+ self.message = message
32
+ self.request_id = request_id
33
+ self.retry_after = retry_after
34
+
35
+
36
+ class GatewayForbiddenError(GatewayError):
37
+ """Caller not permitted (model not allowed / unauthorized / invalid key)."""
38
+
39
+
40
+ class GatewayRateLimitedError(GatewayError):
41
+ """Provider throttle / budget — carries ``retry_after`` when the server sent one."""
42
+
43
+
44
+ class GatewayUpstreamError(GatewayError):
45
+ """Upstream/provider 5xx or gateway self-error — retryable after backoff."""
46
+
47
+
48
+ _ERROR_CODE_MAP: dict[str, type[GatewayError]] = {
49
+ "LLM_MODEL_NOT_ALLOWED": GatewayForbiddenError,
50
+ "LLM_MODEL_UNAVAILABLE": GatewayForbiddenError,
51
+ "LLM_UNAUTHORIZED": GatewayForbiddenError,
52
+ "LLM_INVALID_KEY": GatewayForbiddenError,
53
+ "LLM_INVALID_CALLER_IDENTITY": GatewayForbiddenError,
54
+ "LLM_PROVIDER_RATE_LIMITED": GatewayRateLimitedError,
55
+ "LLM_BUDGET_EXCEEDED": GatewayRateLimitedError,
56
+ "LLM_COST_CEILING_EXCEEDED": GatewayRateLimitedError,
57
+ "LLM_PROVIDER_ERROR": GatewayUpstreamError,
58
+ "LLM_UPSTREAM_ERROR": GatewayUpstreamError,
59
+ "LLM_UPSTREAM_TIMEOUT": GatewayUpstreamError,
60
+ "LLM_PROVIDER_UNAVAILABLE": GatewayUpstreamError,
61
+ "LLM_NO_PROVIDERS_AVAILABLE": GatewayUpstreamError,
62
+ "LLM_INTERNAL_ERROR": GatewayUpstreamError,
63
+ }
64
+
65
+
66
+ def _parse_retry_after(response: httpx.Response) -> int | None:
67
+ raw = response.headers.get("Retry-After")
68
+ if not isinstance(raw, str):
69
+ return None
70
+ try:
71
+ secs = int(raw.strip())
72
+ except ValueError:
73
+ return None
74
+ return secs if secs > 0 else None
75
+
76
+
77
+ def decode_gateway_error(response: httpx.Response) -> GatewayError:
78
+ """Decode a non-2xx response into a typed :class:`GatewayError`.
79
+
80
+ Defensive: a non-JSON / non-envelope body (infra error page) falls back to ``HTTP_<status>``.
81
+ """
82
+ try:
83
+ envelope = (response.json().get("error")) or {}
84
+ code = envelope.get("code") or f"HTTP_{response.status_code}"
85
+ message = envelope.get("message") or response.text[:500]
86
+ request_id = envelope.get("request_id")
87
+ except (ValueError, AttributeError):
88
+ code = f"HTTP_{response.status_code}"
89
+ message = response.text[:500] or response.reason_phrase
90
+ request_id = None
91
+ exc_cls = _ERROR_CODE_MAP.get(code, GatewayError)
92
+ return exc_cls(code=code, message=message, request_id=request_id, retry_after=_parse_retry_after(response))
@@ -0,0 +1,56 @@
1
+ """``GatewayClient`` — chat completions + embeddings via llm-gateway (OpenAI-compatible).
2
+
3
+ Endpoints from ``specs/api/llm-gateway.openapi.yaml``. Identity headers + error decode are
4
+ handled by :class:`~keystone.agent_sdk.clients._base.BaseClient`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from keystone.agent_sdk.clients._base import BaseClient
12
+ from keystone.agent_sdk.tracing import genai_span, link_generation, record_usage
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ _CHAT_PATH = "/api/llm-gateway/v1/chat/completions"
18
+ _EMBEDDINGS_PATH = "/api/llm-gateway/v1/embeddings"
19
+
20
+
21
+ class GatewayClient(BaseClient):
22
+ async def chat(
23
+ self,
24
+ *,
25
+ messages: list[dict[str, str]],
26
+ model: str,
27
+ max_tokens: int = 1024,
28
+ temperature: float = 0.0,
29
+ **extra: Any,
30
+ ) -> dict[str, Any]:
31
+ """OpenAI-compatible chat completion. Returns the raw gateway JSON."""
32
+ body: dict[str, Any] = {
33
+ "model": model,
34
+ "messages": messages,
35
+ "max_tokens": max_tokens,
36
+ "temperature": temperature,
37
+ **extra,
38
+ }
39
+ # FDP-3436: SDK-owned GenAI span (spike Q3) — token usage from the response;
40
+ # no-op when the host process has no tracer (local `agent run`).
41
+ with genai_span("chat", model) as span:
42
+ # FDP-3663: must be INSIDE the span — it stamps that span's id into the body so
43
+ # LiteLLM's generation nests under it instead of dangling at the trace root.
44
+ link_generation(span, body)
45
+ result = await self._post(_CHAT_PATH, body, operation="chat_completion")
46
+ record_usage(span, result)
47
+ return result
48
+
49
+ async def embeddings(self, *, texts: Sequence[str], model: str, **extra: Any) -> dict[str, Any]:
50
+ """Vector embeddings for ``texts``. Returns the raw gateway JSON."""
51
+ body: dict[str, Any] = {"model": model, "input": list(texts), **extra}
52
+ with genai_span("embeddings", model) as span:
53
+ link_generation(span, body)
54
+ result = await self._post(_EMBEDDINGS_PATH, body, operation="embedding")
55
+ record_usage(span, result)
56
+ return result
@@ -0,0 +1,32 @@
1
+ """``RAGClient`` — hybrid search via the rag service.
2
+
3
+ Path verified against the ``services/rag`` search router (prefix ``/knowledge-bases``). ``search``
4
+ targets the multi-KB endpoint (``/api/rag/v1/knowledge-bases/search`` with ``kb_ids`` in the body).
5
+ Response = ``{"results": [{"content", "chunk_id", "score", ...}], ...}`` (no ``data`` envelope).
6
+ Identity headers + error decode via :class:`~keystone.agent_sdk.clients._base.BaseClient`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from keystone.agent_sdk.clients._base import BaseClient
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Sequence
17
+
18
+ _SEARCH_PATH = "/api/rag/v1/knowledge-bases/search"
19
+
20
+
21
+ class RAGClient(BaseClient):
22
+ async def search(
23
+ self,
24
+ *,
25
+ query: str,
26
+ kb_ids: Sequence[str],
27
+ top_k: int = 5,
28
+ **extra: Any,
29
+ ) -> dict[str, Any]:
30
+ """Hybrid retrieval across ``kb_ids``. Returns the raw rag JSON (chunks + citations)."""
31
+ body: dict[str, Any] = {"query": query, "kb_ids": list(kb_ids), "top_k": top_k, **extra}
32
+ return await self._post(_SEARCH_PATH, body, operation="search")
@@ -0,0 +1,156 @@
1
+ """Durable-task discipline for paid / side-effecting steps (M3.3 — FDP-3334).
2
+
3
+ Spike-verified premise (langgraph 1.1.10 + AsyncPostgresSaver, see
4
+ ``services/agent-runtime/tests/integration/test_task_memoization.py``): when a node
5
+ crashes AFTER a ``langgraph.func.task`` call completed, the task's result is already
6
+ persisted with the checkpoint; on resume (``ainvoke(None)`` — the platform worker's
7
+ convention) the node body re-runs but completed tasks **replay from the checkpoint
8
+ instead of re-executing**. Wrap every expensive step (LLM call, paid API, mutation)
9
+ in :func:`durable_task` and a crash-resumed run re-bills only the in-flight step.
10
+
11
+ Two rules make the replay trustworthy, and this module owns both:
12
+
13
+ * **Memo-safe arguments** — replay matches tasks by their deterministic position in
14
+ the node's call sequence, so arguments must be *values*, not live handles or
15
+ freshly-minted timestamps. :func:`durable_task` validates every call's arguments
16
+ and rejects clients / connections / callables (construct them INSIDE the task
17
+ body) and ``datetime`` objects (pass pre-computed ISO strings, or move ``now()``
18
+ inside the task body so it is captured with the memoized result).
19
+ * **Idempotency keys for mutations** — memoization stops the *node-retry* double
20
+ fire, but the crash can still land INSIDE a mutating call (money moved, ticket
21
+ created — then SIGKILL before the result persisted). Downstream dedup is the only
22
+ fix: send :func:`idempotency_key` with every mutating call so the retried call is
23
+ recognised. This gate is review-enforced (``.claude/references/review-checklist.md``),
24
+ not runtime-enforced.
25
+
26
+ Usage::
27
+
28
+ from keystone.agent_sdk import durable_task, idempotency_key
29
+
30
+ @durable_task
31
+ async def draft_reply(ticket: str) -> str: # paid LLM call
32
+ return await llm.ainvoke(ticket) # client built/closed inside
33
+
34
+ async def triage(state: State, config: RunnableConfig) -> dict:
35
+ draft = await draft_reply(state["ticket"]) # replayed, not re-billed, on resume
36
+ key = idempotency_key(
37
+ config["configurable"]["thread_id"], "triage", f"create-ticket:{state['id']}"
38
+ )
39
+ await helpdesk.create(draft, idempotency_key=key)
40
+ return {"draft": draft}
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import hashlib
46
+ from collections.abc import Callable
47
+ from typing import TYPE_CHECKING, Any, overload
48
+
49
+ from langgraph.func import task
50
+ from pydantic import BaseModel
51
+
52
+ if TYPE_CHECKING:
53
+ from langgraph.types import RetryPolicy
54
+
55
+ _SCALARS = (type(None), bool, int, float, str, bytes)
56
+
57
+
58
+ def _assert_memo_safe(value: Any, path: str) -> None:
59
+ """Reject arguments that would make a durable task's replay non-deterministic."""
60
+ if isinstance(value, _SCALARS):
61
+ return
62
+ if isinstance(value, (list, tuple)):
63
+ for i, item in enumerate(value):
64
+ _assert_memo_safe(item, f"{path}[{i}]")
65
+ return
66
+ if isinstance(value, dict):
67
+ for k, item in value.items():
68
+ if not isinstance(k, str):
69
+ raise TypeError(
70
+ f"durable_task argument {path} has a non-string dict key {k!r}; "
71
+ "memo-safe arguments use string keys only"
72
+ )
73
+ _assert_memo_safe(item, f"{path}[{k!r}]")
74
+ return
75
+ if isinstance(value, BaseModel):
76
+ _assert_memo_safe(value.model_dump(), path)
77
+ return
78
+ # datetime/date/time before the generic reject, for a targeted message.
79
+ import datetime as _dt
80
+
81
+ if isinstance(value, (_dt.datetime, _dt.date, _dt.time)):
82
+ raise TypeError(
83
+ f"durable_task argument {path} is a {type(value).__name__} — timestamps make "
84
+ "the replayed call non-deterministic. Pass a pre-computed ISO string, or move "
85
+ "now() inside the task body so it is captured with the memoized result."
86
+ )
87
+ if isinstance(value, (set, frozenset)):
88
+ raise TypeError(
89
+ f"durable_task argument {path} is a {type(value).__name__} — iteration order "
90
+ "is non-deterministic; pass a sorted list instead"
91
+ )
92
+ raise TypeError(
93
+ f"durable_task argument {path} is a live object ({type(value).__module__}."
94
+ f"{type(value).__qualname__}) — clients, connections and other handles must not "
95
+ "cross the durable-task boundary. Construct them INSIDE the task body and pass "
96
+ "plain data (str/int/float/bool/bytes/list/dict/pydantic model) in."
97
+ )
98
+
99
+
100
+ @overload
101
+ def durable_task[F: Callable[..., Any]](fn: F) -> F: ...
102
+ @overload
103
+ def durable_task(
104
+ *, name: str | None = None, retry_policy: RetryPolicy | None = None
105
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
106
+
107
+
108
+ def durable_task(
109
+ fn: Callable[..., Any] | None = None,
110
+ *,
111
+ name: str | None = None,
112
+ retry_policy: RetryPolicy | None = None,
113
+ ) -> Any:
114
+ """Mark a paid / side-effecting step durable: memoized across crash-resume.
115
+
116
+ Thin, validated wrapper over ``langgraph.func.task`` — a resumed run replays the
117
+ completed call's persisted result instead of re-executing it. Call it from inside
118
+ a graph node (sync or async; ``await fn(...)`` / ``fn(...).result()``). Arguments
119
+ are validated per call (see :func:`_assert_memo_safe`); the result must be
120
+ checkpoint-serialisable, exactly like node state.
121
+
122
+ :param name: memo identity (defaults to the function name). Renaming a durable
123
+ task orphans in-flight runs' memoized results — treat the name as a contract.
124
+ :param retry_policy: forwarded to ``langgraph.func.task`` (transient in-process
125
+ retries; distinct from the platform's crash-resume).
126
+ """
127
+
128
+ def decorate(f: Callable[..., Any]) -> Callable[..., Any]:
129
+ inner = task(name=name or f.__name__, retry_policy=retry_policy)(f)
130
+
131
+ def call(*args: Any, **kwargs: Any) -> Any:
132
+ for i, a in enumerate(args):
133
+ _assert_memo_safe(a, f"#{i}")
134
+ for k, a in kwargs.items():
135
+ _assert_memo_safe(a, k)
136
+ return inner(*args, **kwargs)
137
+
138
+ call.__name__ = name or f.__name__
139
+ call.__doc__ = f.__doc__
140
+ call.__wrapped__ = f # type: ignore[attr-defined]
141
+ return call # type: ignore[return-value]
142
+
143
+ return decorate(fn) if fn is not None else decorate
144
+
145
+
146
+ def idempotency_key(thread_id: str, node: str, call: str) -> str:
147
+ """Deterministic dedup key for a mutating call: stable across crash-resume retries.
148
+
149
+ Same logical call → same key on every replay, so the downstream system can drop
150
+ the duplicate (memoization alone cannot help when the crash lands INSIDE the
151
+ mutation). ``thread_id`` comes from the node's ``config["configurable"]["thread_id"]``;
152
+ ``node`` is the node name; ``call`` identifies the call site + its business identity
153
+ (e.g. ``f"refund:{order_id}"``) — include a loop index if the node issues several.
154
+ """
155
+ joined = "\x1f".join((thread_id, node, call))
156
+ return hashlib.sha256(joined.encode("utf-8")).hexdigest()
@@ -0,0 +1,43 @@
1
+ """Human-in-the-loop authoring primitive (M3.4 — FDP-3335 / US-4c).
2
+
3
+ ``request_approval`` pauses the graph via LangGraph ``interrupt()`` and returns the
4
+ approver's decision when the run resumes (``Command(resume=<decision>)`` — the platform's
5
+ respond API sends it in M3.5). The run parks as ``awaiting_human``; days may pass — the
6
+ checkpointer keeps the thread alive and the worker holds NO process meanwhile.
7
+
8
+ Rules (enforced by review; violations = double side-effects or lost pauses):
9
+
10
+ * **Call it EARLY in the node.** On resume the whole node body RE-RUNS from the top up to
11
+ the interrupt point — everything before ``request_approval`` must be idempotent (wrap
12
+ paid steps in ``@durable_task``; give mutations ``idempotency_key``).
13
+ * **ONE ``request_approval`` per node/run (v1).** Multiple or parallel interrupts are out
14
+ of scope: upstream langgraph#6208 re-runs a two-interrupt node after one resume, and the
15
+ platform enforces a single OPEN pending per run (partial unique index).
16
+ * The payload must be plain JSON-able data — it is stored in ``hitl_pending`` and shown to
17
+ the approver verbatim.
18
+
19
+ Usage::
20
+
21
+ from keystone.agent_sdk import request_approval
22
+
23
+ async def escalate(state: State) -> dict:
24
+ decision = request_approval({"kind": "escalation", "draft": state["reply"]})
25
+ if not decision.get("approved"):
26
+ return {"reply": decision.get("note", "Escalation rejected.")}
27
+ ...
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Any
33
+
34
+ from langgraph.types import interrupt
35
+
36
+
37
+ def request_approval(payload: dict[str, Any]) -> Any:
38
+ """Pause the run for human approval; returns the approver's decision on resume.
39
+
40
+ :param payload: JSON-able approval request (draft, kind, …) shown to the approver.
41
+ :returns: whatever the approver responds with (the platform sends a dict).
42
+ """
43
+ return interrupt(payload)
@@ -0,0 +1,62 @@
1
+ """Load an agent's compiled LangGraph from its manifest entrypoint (FDP-3120).
2
+
3
+ Backs ``keystone agent run`` (the CLI local loop) and is available to any host needing a
4
+ compiled graph from an ``entrypoint`` string. The module is resolved relative to ``project_dir``
5
+ (the agent project root) — mirroring :func:`keystone.agent_sdk.validate`'s import model — so a
6
+ checked-out agent project runs with no install. No checkpointer here (local/dev); the durable
7
+ runtime compiles with its own checkpointer (agent-runtime, FDP-3115).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from typing import Any
19
+
20
+
21
+ class GraphLoadError(Exception):
22
+ """An agent entrypoint could not be imported / built / compiled into a graph."""
23
+
24
+
25
+ def load_graph(entrypoint: str, project_dir: str | Path | None = None) -> Any:
26
+ """Import ``module.path:factory``, call the factory, and return the **compiled** graph.
27
+
28
+ :param entrypoint: ``"module.path:factory"`` (the manifest's entrypoint).
29
+ :param project_dir: agent project root prepended to ``sys.path`` for the import (removed
30
+ afterwards). ``None`` → rely on the current ``sys.path``.
31
+ :raises GraphLoadError: malformed entrypoint / unimportable module / missing-or-uncallable
32
+ factory / a graph that fails to build or compile.
33
+ """
34
+ module_name, sep, factory_name = entrypoint.partition(":")
35
+ if sep != ":" or not module_name.strip() or not factory_name.strip():
36
+ raise GraphLoadError(f"invalid entrypoint {entrypoint!r} (expected 'module.path:factory')")
37
+
38
+ added: str | None = None
39
+ if project_dir is not None:
40
+ resolved = str(Path(project_dir).resolve())
41
+ if resolved not in sys.path:
42
+ sys.path.insert(0, resolved)
43
+ added = resolved
44
+ try:
45
+ try:
46
+ module = importlib.import_module(module_name)
47
+ except Exception as exc: # ModuleNotFoundError + any import-time error
48
+ raise GraphLoadError(f"cannot import entrypoint module {module_name!r}: {exc}") from exc
49
+
50
+ factory = getattr(module, factory_name, None)
51
+ if not callable(factory):
52
+ raise GraphLoadError(f"entrypoint factory {factory_name!r} not found or not callable in {module_name!r}")
53
+
54
+ try:
55
+ graph = factory()
56
+ except Exception as exc:
57
+ raise GraphLoadError(f"entrypoint {entrypoint!r} failed to build: {exc}") from exc
58
+ # A LangGraph StateGraph must be compiled; a pre-compiled graph is returned as-is.
59
+ return graph.compile() if hasattr(graph, "compile") else graph
60
+ finally:
61
+ if added is not None:
62
+ sys.path.remove(added)
@@ -0,0 +1,141 @@
1
+ """``agent.yaml`` manifest model — the single schema the SDK owns and shares across
2
+ ``init`` / ``validate`` / ``deploy`` (FDP-3072 §3.5b). Keys mirror visual-builder's
3
+ ``kind: Agent`` for stack convergence.
4
+
5
+ Design rules baked in as validators:
6
+
7
+ * ``entrypoint`` must be ``"module.path:factory"`` (the runtime graph-loader contract).
8
+ * ``secrets`` carry **references only**, never inline values (a pod mounts its own
9
+ secrets by ref — proposal §5b). An inline value is a validation error.
10
+ * ``extra="forbid"`` everywhere — an unknown/typo'd key fails fast rather than being
11
+ silently ignored.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any, Literal
18
+
19
+ import yaml
20
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
21
+
22
+
23
+ class ResourceSpec(BaseModel):
24
+ """Deployment resources (→ agent-hub ``DeploymentEntity`` in M2)."""
25
+
26
+ model_config = ConfigDict(extra="forbid")
27
+
28
+ cpu: str = "500m"
29
+ memory: str = "512Mi"
30
+ replicas: int = Field(default=1, ge=0)
31
+ # Per-ATTEMPT execution budgets (D-M3-G, FDP-3332): sync in-request runs get
32
+ # ``timeout_seconds``; async (queued) runs get ``async_timeout_seconds``. NOT the queue
33
+ # wait, NOT the HITL wait (interrupt() returns immediately). Both are clamped by the
34
+ # platform cap (RT_MAX_RUN_SECONDS, default 7200) at the runtime.
35
+ timeout_seconds: int = Field(default=60, gt=0)
36
+ async_timeout_seconds: int = Field(default=600, gt=0)
37
+ # Canary / rollback are enforced by the agent-gateway in M2; declared here so the
38
+ # manifest schema is stable from M1.
39
+ canary_percentage: int | None = Field(default=None, ge=0, le=100)
40
+ auto_rollback_threshold: float | None = Field(default=None, ge=0)
41
+
42
+
43
+ class HitlSpec(BaseModel):
44
+ """Approval-timeout policy (M3.6 / FDP-3337) for ``request_approval`` pauses.
45
+
46
+ The runtime denormalises ``timeout_seconds`` into ``hitl_pending.expires_at`` when the
47
+ run parks; the 5-minute recovery sweep then applies ``on_timeout``:
48
+
49
+ * ``"expire"`` (default) — the pending and the run both become terminal ``expired``.
50
+ * ``"reject"`` — the run resumes with ``{"approved": False, "timed_out": True}``, so
51
+ it flows the graph's NORMAL rejection branch (write one — don't assume approval).
52
+ """
53
+
54
+ model_config = ConfigDict(extra="forbid")
55
+
56
+ timeout_seconds: int = Field(default=259200, gt=0) # 72h
57
+ on_timeout: Literal["expire", "reject"] = "expire"
58
+ # Who may answer this agent's approvals (platform user ids — the portal identity).
59
+ # EMPTY (default) = anyone in the run's entity+workspace (the accepted v1 behavior).
60
+ # Non-empty = respond is rejected with 403 RT_HITL_FORBIDDEN for anyone else.
61
+ approvers: list[str] = Field(default_factory=list)
62
+
63
+
64
+ class Dependencies(BaseModel):
65
+ """Building-block services + models + knowledge bases the agent needs (preflighted for quota/RBAC)."""
66
+
67
+ model_config = ConfigDict(extra="forbid")
68
+
69
+ services: list[str] = Field(default_factory=list)
70
+ models: list[str] = Field(default_factory=list)
71
+ # FDP-3072 P2 (ACL auto-grant): the KBs this agent queries, declared EXPLICITLY here rather than
72
+ # buried in freeform `config` env — so agent-hub can grant `knowledge-base:query` per-KB at
73
+ # publish (least-privilege) instead of over-granting at the workspace level. Optional: an agent
74
+ # with no KB (pure-LLM) leaves it empty and simply gets no KB grant. Values are KB UUIDs.
75
+ knowledge_bases: list[str] = Field(default_factory=list)
76
+
77
+
78
+ class Identity(BaseModel):
79
+ """The agent's runtime identity scopes (provisioned by agent-hub at deploy)."""
80
+
81
+ model_config = ConfigDict(extra="forbid")
82
+
83
+ scopes: list[str] = Field(default_factory=list)
84
+
85
+
86
+ class SecretRef(BaseModel):
87
+ """A secret **reference** (never a value). ``ref`` is a URI like ``aws-sm://kv/api-key``."""
88
+
89
+ model_config = ConfigDict(extra="forbid")
90
+
91
+ name: str
92
+ ref: str
93
+
94
+ @field_validator("ref")
95
+ @classmethod
96
+ def _must_be_a_reference(cls, v: str) -> str:
97
+ if "://" not in v:
98
+ raise ValueError(
99
+ "secret 'ref' must be a reference URI (e.g. 'aws-sm://kv/api-key'), never an inline secret value"
100
+ )
101
+ return v
102
+
103
+
104
+ class AgentManifest(BaseModel):
105
+ """The parsed ``agent.yaml``."""
106
+
107
+ model_config = ConfigDict(extra="forbid")
108
+
109
+ name: str
110
+ workspace: str
111
+ framework: str = "langgraph@1"
112
+ entrypoint: str # "module.path:factory"
113
+ runtime: str = "python3.12"
114
+ resources: ResourceSpec = Field(default_factory=ResourceSpec)
115
+ hitl: HitlSpec = Field(default_factory=HitlSpec)
116
+ io_schema: dict[str, Any] = Field(default_factory=dict)
117
+ dependencies: Dependencies = Field(default_factory=Dependencies)
118
+ tools: list[str] = Field(default_factory=list)
119
+ guardrails: list[str] = Field(default_factory=list)
120
+ identity: Identity = Field(default_factory=Identity)
121
+ secrets: list[SecretRef] = Field(default_factory=list)
122
+ # Per-agent config the DEV owns — injected as environment variables into the agent's pod
123
+ # (per-version, immutable) so the agent's code reads it via ``os.environ`` (e.g. a KB id or a
124
+ # model name). Keys are env-var names, values are strings. NOT for secrets — those go in
125
+ # ``secrets`` as references. Replaces stuffing per-agent values into the shared runtime
126
+ # ConfigMap (FDP-3306).
127
+ config: dict[str, str] = Field(default_factory=dict)
128
+
129
+ @field_validator("entrypoint")
130
+ @classmethod
131
+ def _entrypoint_shape(cls, v: str) -> str:
132
+ module, _, factory = v.partition(":")
133
+ if v.count(":") != 1 or not module.strip() or not factory.strip():
134
+ raise ValueError("entrypoint must be 'module.path:factory' (exactly one ':')")
135
+ return v
136
+
137
+
138
+ def load_manifest(path: str | Path) -> AgentManifest:
139
+ """Load + validate an ``agent.yaml`` from disk into an :class:`AgentManifest`."""
140
+ data = yaml.safe_load(Path(path).read_text()) or {}
141
+ return AgentManifest.model_validate(data)
@@ -0,0 +1,109 @@
1
+ """Where the deployed source came from — git commit + clean/dirty (deploy provenance).
2
+
3
+ ``create_bundle`` packages the **working directory**, not a git ref. That is deliberate (a dev can
4
+ deploy without pushing), but it means "what is running in dev?" has no answer beyond a content hash:
5
+ the same `bundle_sha256` tells you two uploads were identical, never which commit produced them, and
6
+ nothing at all if the tree had uncommitted edits. LangSmith gets this for free by building from a git
7
+ repo; we have to record it ourselves. This module is the recording half.
8
+
9
+ Deliberately NOT written into the bundle. ``create_bundle``'s hash is a pure function of the source —
10
+ that property is what makes a re-deploy dedupe to the existing version — and a provenance file inside
11
+ the tar would make the same code hash differently from two commits. So it travels beside the upload.
12
+
13
+ Everything here fails **soft**: a project that is not a git repo, a repo with no commits, a missing
14
+ git binary, or a hung index lock all yield ``None``. Provenance is metadata; refusing to deploy
15
+ without it would break the "scaffold and ship" path the templates advertise.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import shutil
21
+ import subprocess # noqa: S404 — git plumbing, fixed argv, never shell=True # nosec B404
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ # git can block on an index.lock held by another process; a deploy must not hang on metadata.
26
+ _GIT_TIMEOUT_S = 5.0
27
+
28
+ # A branch name is unbounded in git but this ends up in a jsonb column, so cap it here rather than
29
+ # letting the server be the only guard.
30
+ _MAX_BRANCH_LEN = 255
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SourceProvenance:
35
+ """The commit the bundle was built from, and whether the tree matched it.
36
+
37
+ ``dirty`` is the load-bearing field. A clean tree means the bundle is reproducible from
38
+ ``commit``; a dirty one means it is **not**, and no amount of commit-pinning recovers it. That is
39
+ the case worth warning about, because it is the one where "which code is running?" becomes
40
+ unanswerable after the fact.
41
+ """
42
+
43
+ commit: str
44
+ dirty: bool
45
+ branch: str | None = None
46
+
47
+ def as_payload(self) -> dict[str, object]:
48
+ """The wire form sent alongside the bundle upload."""
49
+ out: dict[str, object] = {"commit": self.commit, "dirty": self.dirty}
50
+ if self.branch:
51
+ out["branch"] = self.branch
52
+ return out
53
+
54
+ def summary(self) -> str:
55
+ """One-line human form, e.g. ``a1b2c3d (main, dirty)``."""
56
+ bits = [self.branch] if self.branch else []
57
+ if self.dirty:
58
+ bits.append("dirty")
59
+ suffix = f" ({', '.join(bits)})" if bits else ""
60
+ return f"{self.commit[:7]}{suffix}"
61
+
62
+
63
+ def detect(project_dir: str | Path) -> SourceProvenance | None:
64
+ """Best-effort provenance for ``project_dir``, or ``None`` when it cannot be determined."""
65
+ root = Path(project_dir)
66
+ if _git(root, "rev-parse", "--is-inside-work-tree") != "true":
67
+ return None
68
+ commit = _git(root, "rev-parse", "HEAD")
69
+ if not commit:
70
+ return None # a repo with no commits yet — nothing to pin to
71
+
72
+ # Scoped to THIS directory (`-- .`), not the whole repo: an agent project can live inside a
73
+ # monorepo, and unrelated edits elsewhere say nothing about whether this bundle is reproducible.
74
+ # `--porcelain` honours .gitignore, so an ignored file (.env) correctly does not mark it dirty,
75
+ # while an untracked non-ignored file does — that file IS packaged, so the tree really is not
76
+ # reproducible from the commit.
77
+ status = _git(root, "status", "--porcelain", "--", ".")
78
+ branch = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
79
+ if branch == "HEAD": # detached — no name to report
80
+ branch = None
81
+ return SourceProvenance(
82
+ commit=commit,
83
+ dirty=bool(status),
84
+ branch=branch[:_MAX_BRANCH_LEN] if branch else None,
85
+ )
86
+
87
+
88
+ def _git(cwd: Path, *args: str) -> str | None:
89
+ """Run one git plumbing command in ``cwd``; ``None`` on any failure. Never raises."""
90
+ # Absolute path, resolved from PATH at call time. A partial argv[0] is a PATH-injection
91
+ # foothold, and resolving here keeps the function self-contained so a test can change PATH.
92
+ # Binary missing → the same soft failure as every other error path in this module.
93
+ git = shutil.which("git")
94
+ if git is None:
95
+ return None
96
+ try:
97
+ # Fixed argv, never shell=True, cwd is caller-controlled only.
98
+ proc = subprocess.run( # noqa: S603 # nosec B603
99
+ [git, *args],
100
+ cwd=cwd,
101
+ capture_output=True,
102
+ text=True,
103
+ timeout=_GIT_TIMEOUT_S,
104
+ check=False,
105
+ )
106
+ except (OSError, subprocess.SubprocessError):
107
+ # No git binary, cwd vanished, or the call timed out on a lock.
108
+ return None
109
+ return proc.stdout.strip() if proc.returncode == 0 else None
File without changes
@@ -0,0 +1,100 @@
1
+ """GenAI client spans + trace propagation (FDP-3436 / spike Q3).
2
+
3
+ ``GatewayClient`` speaks plain httpx (OpenAI-compat REST, not the ``openai`` client), so
4
+ NO auto-instrumentor sees the platform's LLM calls — the SDK emits the spans itself, with
5
+ the ``gen_ai.*`` semconv attributes the observability redactor whitelists (token counts,
6
+ models). Depends on ``opentelemetry-api`` ONLY: without a configured provider (local
7
+ ``keystone agent run``), every span here is a no-op — zero config, zero overhead. The
8
+ agent-runtime harness installs the real provider at pod boot (``observability.tracing``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from contextlib import contextmanager
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from opentelemetry import trace
17
+ from opentelemetry.trace import SpanKind
18
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterator
22
+
23
+ # ProxyTracer: bound lazily to whatever provider the host process installs later.
24
+ _tracer = trace.get_tracer("keystone.agent_sdk")
25
+ _propagator = TraceContextTextMapPropagator()
26
+
27
+
28
+ @contextmanager
29
+ def genai_span(operation: str, model: str) -> Iterator[Any]:
30
+ """CLIENT span for one llm-gateway call, named per GenAI semconv (``chat gemma``).
31
+
32
+ Typed as a plain SPAN for Langfuse, not a generation (FDP-3663). Langfuse infers
33
+ "generation" from any span carrying a ``model`` attribute, so this span — which describes the
34
+ HTTP hop to the gateway — was being counted as a second generation alongside LiteLLM's real
35
+ one. Measured on dev: one LLM call produced two GENERATION observations both reporting
36
+ ``41 → 129`` tokens. Cost stayed correct (this span's cost is 0), but any consumer summing
37
+ tokens across generations would double them.
38
+
39
+ ``langfuse.observation.type`` is the documented override, so the ``gen_ai.*`` semconv
40
+ attributes stay — dropping them to dodge the inference would throw away correct OTel data and
41
+ the token counts a non-Langfuse backend reads.
42
+
43
+ Trade-off worth knowing: LiteLLM's callback is now the ONLY generation for the call. If the
44
+ gateway's Langfuse callback is ever disabled, the trace keeps this span (with its ``gen_ai.*``
45
+ usage attributes) but loses the generation view.
46
+ """
47
+ with _tracer.start_as_current_span(f"{operation} {model}", kind=SpanKind.CLIENT) as span:
48
+ span.set_attribute("gen_ai.operation.name", operation)
49
+ span.set_attribute("gen_ai.system", "keystone.llm-gateway")
50
+ span.set_attribute("gen_ai.request.model", model)
51
+ span.set_attribute("langfuse.observation.type", "span")
52
+ yield span
53
+
54
+
55
+ def link_generation(span: Any, body: dict[str, Any]) -> None:
56
+ """Tell llm-gateway which observation LiteLLM's generation belongs UNDER (FDP-3663).
57
+
58
+ The gateway forwards ``body.metadata`` into the LiteLLM metadata
59
+ (``build_litellm_metadata(extra=body.metadata)``), and LiteLLM's Langfuse callback nests its
60
+ generation under ``parent_observation_id``. Without it the generation attaches to the trace
61
+ ROOT as a sibling of ``agent.run``, so its cost never rolls up into the agent's own span —
62
+ measured on dev: the trace root showed ``$0.002402`` while ``agent.run`` showed ``$0.000000``.
63
+
64
+ Parented to the CLIENT span, i.e. the generation becomes a child of ``chat <model>``, because
65
+ that is the real nesting: our span covers the HTTP call to the gateway and the LLM call happens
66
+ inside it (durations agree — a 4.79s generation inside a 5.09s ``chat``). Making them siblings
67
+ would claim the two ran side by side.
68
+
69
+ ``setdefault`` twice on purpose: a caller passing its own ``metadata`` (or its own
70
+ ``parent_observation_id``) keeps it. No tracer / non-recording span → span id 0 → no-op, so a
71
+ local ``keystone agent run`` sends no metadata at all.
72
+ """
73
+ try:
74
+ span_id = span.get_span_context().span_id
75
+ except Exception: # pragma: no cover — defensive: tracing must never break a call
76
+ return
77
+ if not span_id:
78
+ return
79
+ meta = body.setdefault("metadata", {})
80
+ if isinstance(meta, dict):
81
+ meta.setdefault("parent_observation_id", format(span_id, "016x"))
82
+
83
+
84
+ def record_usage(span: Any, response: dict[str, Any]) -> None:
85
+ """Stamp token usage from an OpenAI-compat response onto the span (safe no-op on
86
+ non-recording spans / absent ``usage``)."""
87
+ usage = response.get("usage") or {}
88
+ if "prompt_tokens" in usage:
89
+ span.set_attribute("gen_ai.usage.input_tokens", int(usage["prompt_tokens"]))
90
+ if "completion_tokens" in usage:
91
+ span.set_attribute("gen_ai.usage.output_tokens", int(usage["completion_tokens"]))
92
+ if response.get("model"):
93
+ span.set_attribute("gen_ai.response.model", str(response["model"]))
94
+
95
+
96
+ def inject_traceparent(headers: dict[str, str]) -> dict[str, str]:
97
+ """Add the W3C ``traceparent`` for the CURRENT span context to outbound headers —
98
+ agent → rag/llm-gateway hops join the run's trace. No active span → no-op."""
99
+ _propagator.inject(headers)
100
+ return headers
@@ -0,0 +1,69 @@
1
+ """``validate()`` — check an ``agent.yaml`` is well-formed and its entrypoint is
2
+ importable, *without* deploying. Backs ``keystone agent validate`` (FDP-3120) and the
3
+ deploy preflight (M2).
4
+
5
+ The entrypoint module is resolved **relative to the manifest's directory** (the agent
6
+ project root), so ``entrypoint: rag_qa.graph:make_graph`` imports from the project the
7
+ manifest lives in — no install required.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import sys
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from pydantic import ValidationError
18
+
19
+ from .manifest import AgentManifest, load_manifest
20
+
21
+
22
+ @dataclass
23
+ class ValidationResult:
24
+ """Outcome of :func:`validate`. ``ok`` is ``True`` only when ``errors`` is empty."""
25
+
26
+ ok: bool
27
+ errors: list[str] = field(default_factory=list)
28
+ manifest: AgentManifest | None = None
29
+
30
+
31
+ def validate(manifest_path: str | Path) -> ValidationResult:
32
+ """Validate the manifest at ``manifest_path`` + that its entrypoint is importable."""
33
+ path = Path(manifest_path)
34
+ if not path.is_file():
35
+ return ValidationResult(ok=False, errors=[f"manifest not found: {path}"])
36
+
37
+ try:
38
+ manifest = load_manifest(path)
39
+ except ValidationError as e:
40
+ return ValidationResult(ok=False, errors=[f"manifest invalid: {e}"])
41
+ except Exception as e: # noqa: BLE001 — surface any YAML/read error as a validation error
42
+ return ValidationResult(ok=False, errors=[f"manifest unreadable: {e}"])
43
+
44
+ errors = _check_entrypoint_importable(manifest.entrypoint, path.resolve().parent)
45
+ return ValidationResult(ok=not errors, errors=errors, manifest=manifest)
46
+
47
+
48
+ def _check_entrypoint_importable(entrypoint: str, project_dir: Path) -> list[str]:
49
+ module_name, _, factory_name = entrypoint.partition(":")
50
+ project_dir_str = str(project_dir)
51
+ added = project_dir_str not in sys.path
52
+ if added:
53
+ sys.path.insert(0, project_dir_str)
54
+ try:
55
+ module = importlib.import_module(module_name)
56
+ except ModuleNotFoundError as e:
57
+ return [f"entrypoint module '{module_name}' is not importable: {e}"]
58
+ except Exception as e: # noqa: BLE001 — an import-time error is still a bad entrypoint
59
+ return [f"entrypoint module '{module_name}' raised on import: {e}"]
60
+ finally:
61
+ if added:
62
+ sys.path.remove(project_dir_str)
63
+
64
+ factory = getattr(module, factory_name, None)
65
+ if factory is None:
66
+ return [f"entrypoint factory '{factory_name}' not found in module '{module_name}'"]
67
+ if not callable(factory):
68
+ return [f"entrypoint '{entrypoint}' is not callable"]
69
+ return []
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: keystone-agent-sdk
3
+ Version: 0.3.0
4
+ Summary: Keystone pro-code agent SDK — LangGraph authoring primitives (StateGraph, @tool), the agent.yaml manifest model, and validate(). FR-04 / FDP-3072 (M1).
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: langgraph<2,>=1
7
+ Requires-Dist: langchain-core<2,>=0.3
8
+ Requires-Dist: pydantic<3,>=2
9
+ Requires-Dist: pyyaml<7,>=6
10
+ Requires-Dist: httpx<1,>=0.27
11
+ Requires-Dist: keystone-request-context<0.4,>=0.3
12
+ Requires-Dist: opentelemetry-api<2,>=1.28
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest<9,>=8; extra == "dev"
15
+ Requires-Dist: pytest-cov<7,>=6; extra == "dev"
16
+ Requires-Dist: ruff==0.15.15; extra == "dev"
17
+ Requires-Dist: opentelemetry-sdk<2,>=1.28; extra == "dev"
@@ -0,0 +1,19 @@
1
+ keystone/agent_sdk/__init__.py,sha256=2UDvZTzaWCgIgp0BmSBOA9xW0Xlmstu4UgGqs8Qha_s,1945
2
+ keystone/agent_sdk/bundle.py,sha256=BM8CcWVV3MYqR8YXnK3R66b1iGdFJdthJb5dGJkgP1Y,7998
3
+ keystone/agent_sdk/durable.py,sha256=Eq6Op_IZ0zQnIkkvrKvdzsTFJodMrrZNmuOsx6OQ9YI,7042
4
+ keystone/agent_sdk/hitl.py,sha256=ZY3LXzq_1UaurvTLScz7zKAaxWPlt5QWBqEknYGuEmQ,1892
5
+ keystone/agent_sdk/loader.py,sha256=BgAdreekbgMSsMaGfCNOA1YLSopDwISCtAwoKbl8yKA,2734
6
+ keystone/agent_sdk/manifest.py,sha256=W33hdnPNWYpEiqZDb_4pQnGIik8vIexqvXcsAVu2LTk,6006
7
+ keystone/agent_sdk/provenance.py,sha256=2xqgyajc_Vy4_7uJUiDUuYjISC-4wWkWjdp9jbcPZ4U,4938
8
+ keystone/agent_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ keystone/agent_sdk/tracing.py,sha256=8fwXAJf0e7JJuldtfraynxVPvoel6wd8IkVUHnrOLDE,5107
10
+ keystone/agent_sdk/validate.py,sha256=snMxyYQQW8RBg0EneNBNHvmJnIhA1_Jq4Gsn17vdnUo,2644
11
+ keystone/agent_sdk/clients/__init__.py,sha256=j62baSqK-aKbORgSIHTnIzkfyZO_F18LTwJ9TBg7TqQ,882
12
+ keystone/agent_sdk/clients/_base.py,sha256=SNyJsa74E89p-Qy7-cquDeARUhrcZpOFqj78wpywb5Q,2256
13
+ keystone/agent_sdk/clients/_errors.py,sha256=8Lc4fzgpXRDN6xEhFlmPLHzCg0u4Y9ZkcYpuIrwTARE,3347
14
+ keystone/agent_sdk/clients/gateway.py,sha256=kBOOBxHCxQ31iabiMzZ9T25OobGSAJwN-uzdz7EaeYQ,2243
15
+ keystone/agent_sdk/clients/rag.py,sha256=UL3_OGi6V2dMl6rMwTD5c17bW-r7zvZMTZcXB5_HXyc,1159
16
+ keystone_agent_sdk-0.3.0.dist-info/METADATA,sha256=VleY9HOJjbm4C568SyQrLjDHzF2J8iCaxtT0p1D3pdI,713
17
+ keystone_agent_sdk-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
+ keystone_agent_sdk-0.3.0.dist-info/top_level.txt,sha256=XS9DabbjrYNyRjQpLiI8n1RiT-5pV3BRO7CLSCg68ZY,9
19
+ keystone_agent_sdk-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ keystone