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.
- keystone/agent_sdk/__init__.py +47 -0
- keystone/agent_sdk/bundle.py +183 -0
- keystone/agent_sdk/clients/__init__.py +27 -0
- keystone/agent_sdk/clients/_base.py +56 -0
- keystone/agent_sdk/clients/_errors.py +92 -0
- keystone/agent_sdk/clients/gateway.py +56 -0
- keystone/agent_sdk/clients/rag.py +32 -0
- keystone/agent_sdk/durable.py +156 -0
- keystone/agent_sdk/hitl.py +43 -0
- keystone/agent_sdk/loader.py +62 -0
- keystone/agent_sdk/manifest.py +141 -0
- keystone/agent_sdk/provenance.py +109 -0
- keystone/agent_sdk/py.typed +0 -0
- keystone/agent_sdk/tracing.py +100 -0
- keystone/agent_sdk/validate.py +69 -0
- keystone_agent_sdk-0.3.0.dist-info/METADATA +17 -0
- keystone_agent_sdk-0.3.0.dist-info/RECORD +19 -0
- keystone_agent_sdk-0.3.0.dist-info/WHEEL +5 -0
- keystone_agent_sdk-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
keystone
|