keystone-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keystone_cli/__init__.py +4 -0
- keystone_cli/__main__.py +24 -0
- keystone_cli/auth/__init__.py +5 -0
- keystone_cli/auth/device_flow.py +197 -0
- keystone_cli/auth/token_store.py +71 -0
- keystone_cli/commands/__init__.py +1 -0
- keystone_cli/commands/agent.py +1017 -0
- keystone_cli/commands/dev.py +57 -0
- keystone_cli/commands/login.py +207 -0
- keystone_cli/commands/workspace.py +87 -0
- keystone_cli/devloop.py +183 -0
- keystone_cli/platform_client.py +75 -0
- keystone_cli/runner.py +87 -0
- keystone_cli/scaffold.py +81 -0
- keystone_cli/templates/blank/README.md.tmpl +25 -0
- keystone_cli/templates/blank/agent.yaml.tmpl +20 -0
- keystone_cli/templates/blank/env.tmpl +10 -0
- keystone_cli/templates/blank/gitignore.tmpl +7 -0
- keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/blank/pkg/graph.py.tmpl +33 -0
- keystone_cli/templates/blank/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/hitl/README.md.tmpl +33 -0
- keystone_cli/templates/hitl/agent.yaml.tmpl +33 -0
- keystone_cli/templates/hitl/env.tmpl +10 -0
- keystone_cli/templates/hitl/gitignore.tmpl +7 -0
- keystone_cli/templates/hitl/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/hitl/pkg/graph.py.tmpl +115 -0
- keystone_cli/templates/hitl/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/llm/README.md.tmpl +29 -0
- keystone_cli/templates/llm/agent.yaml.tmpl +29 -0
- keystone_cli/templates/llm/env.tmpl +10 -0
- keystone_cli/templates/llm/gitignore.tmpl +7 -0
- keystone_cli/templates/llm/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/llm/pkg/graph.py.tmpl +53 -0
- keystone_cli/templates/llm/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/rag-qa/README.md.tmpl +23 -0
- keystone_cli/templates/rag-qa/agent.yaml.tmpl +34 -0
- keystone_cli/templates/rag-qa/env.tmpl +10 -0
- keystone_cli/templates/rag-qa/gitignore.tmpl +7 -0
- keystone_cli/templates/rag-qa/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +69 -0
- keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/tool-agent/README.md.tmpl +30 -0
- keystone_cli/templates/tool-agent/agent.yaml.tmpl +25 -0
- keystone_cli/templates/tool-agent/env.tmpl +10 -0
- keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
- keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +89 -0
- keystone_cli/templates/tool-agent/pyproject.toml.tmpl +15 -0
- keystone_cli-0.1.0.dist-info/METADATA +13 -0
- keystone_cli-0.1.0.dist-info/RECORD +54 -0
- keystone_cli-0.1.0.dist-info/WHEEL +5 -0
- keystone_cli-0.1.0.dist-info/entry_points.txt +2 -0
- keystone_cli-0.1.0.dist-info/top_level.txt +1 -0
keystone_cli/runner.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Local graph run for ``keystone agent run`` (FDP-3120): per-node trace + ``--offline`` mock.
|
|
2
|
+
|
|
3
|
+
The runner sets a LOCAL caller-identity ContextVar (so the SDK clients build headers) and, in
|
|
4
|
+
offline mode, patches the SDK building-block clients so a run needs no live services / quota.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from contextlib import contextmanager, nullcontext
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from keystone.agent_sdk import load_graph
|
|
15
|
+
from keystone.request_context import LLMCallerIdentity, reset_caller_identity, set_caller_identity
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# Minimal canned responses so an agent graph RUNS end-to-end offline (proves wiring, not real
|
|
22
|
+
# answers). Keyed by the client operation (X-Caller-Operation). Record-replay cassettes = later.
|
|
23
|
+
_OFFLINE_STUBS: dict[str, dict[str, Any]] = {
|
|
24
|
+
"chat_completion": {"choices": [{"message": {"content": "[offline stub answer]"}, "finish_reason": "stop"}]},
|
|
25
|
+
"embedding": {"data": [{"embedding": [0.0, 0.0, 0.0]}]},
|
|
26
|
+
"search": {"results": [{"chunk_id": "offline-1", "content": "[offline stub context]"}], "total": 1, "query_ms": 0},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_agent(*, entrypoint: str, project_dir: Path, inputs: dict[str, Any], offline: bool) -> dict[str, Any]:
|
|
31
|
+
"""Load + run the agent graph locally with a per-node trace. Returns the final state."""
|
|
32
|
+
offline_ctx: Any = _offline_clients() if offline else nullcontext()
|
|
33
|
+
with _local_identity(), offline_ctx:
|
|
34
|
+
graph = load_graph(entrypoint, project_dir=project_dir)
|
|
35
|
+
return asyncio.run(_astream_with_trace(graph, inputs))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _astream_with_trace(graph: Any, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
final: dict[str, Any] = {}
|
|
40
|
+
# Two stream modes at once: "updates" names each node (trace); "values" carries the full
|
|
41
|
+
# state so the LAST one is the true final result.
|
|
42
|
+
async for mode, chunk in graph.astream(inputs, stream_mode=["updates", "values"]):
|
|
43
|
+
if mode == "updates":
|
|
44
|
+
for node, delta in chunk.items():
|
|
45
|
+
typer.secho(f" → {node}: {delta}", fg=typer.colors.CYAN)
|
|
46
|
+
else:
|
|
47
|
+
final = chunk
|
|
48
|
+
return dict(final)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def _local_identity() -> Iterator[None]:
|
|
53
|
+
"""Bind a dev/local caller identity so the SDK clients' header builder has a context."""
|
|
54
|
+
token = set_caller_identity(
|
|
55
|
+
LLMCallerIdentity(
|
|
56
|
+
entity_id="local-dev",
|
|
57
|
+
stage_id="", # Stage tier retired (FDP-2071) — field kept by the lib, value unused
|
|
58
|
+
workspace_id="local-dev",
|
|
59
|
+
correlation_id="cli-run",
|
|
60
|
+
agent_id="local-cli",
|
|
61
|
+
agent_version="1",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
yield
|
|
66
|
+
finally:
|
|
67
|
+
reset_caller_identity(token)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@contextmanager
|
|
71
|
+
def _offline_clients() -> Iterator[None]:
|
|
72
|
+
"""Patch the SDK clients' transport so building-block calls return canned stubs (no network).
|
|
73
|
+
|
|
74
|
+
Imported lazily so ``validate`` (and online ``run``) don't pull the client/httpx stack.
|
|
75
|
+
"""
|
|
76
|
+
from keystone.agent_sdk.clients import _base
|
|
77
|
+
|
|
78
|
+
async def _stub_post(self: Any, path: str, body: dict[str, Any], *, operation: str) -> dict[str, Any]:
|
|
79
|
+
typer.secho(f" [offline] {operation} {path}", fg=typer.colors.YELLOW)
|
|
80
|
+
return _OFFLINE_STUBS.get(operation, {})
|
|
81
|
+
|
|
82
|
+
original = _base.BaseClient._post
|
|
83
|
+
_base.BaseClient._post = _stub_post # type: ignore[method-assign]
|
|
84
|
+
try:
|
|
85
|
+
yield
|
|
86
|
+
finally:
|
|
87
|
+
_base.BaseClient._post = original # type: ignore[method-assign]
|
keystone_cli/scaffold.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Project scaffolding for ``keystone agent init`` (FDP-3440 / US-3b).
|
|
2
|
+
|
|
3
|
+
Templates ship as PACKAGE DATA (``keystone_cli/templates/<name>/**/*.tmpl``) — embedded,
|
|
4
|
+
never fetched from the network (corporate-proxy-proof, unlike ``langgraph new``). Files
|
|
5
|
+
render with plain placeholder substitution (no template engine): ``__AGENT_NAME__``,
|
|
6
|
+
``__PKG__`` (name with ``-`` → ``_``), ``__WORKSPACE__``. The generated project follows
|
|
7
|
+
the platform layout the SDK loader expects (flat ``<pkg>/graph.py`` next to ``agent.yaml``
|
|
8
|
+
— what ``validate()`` puts on ``sys.path``) and passes ``keystone agent validate`` +
|
|
9
|
+
``keystone agent run . --offline`` out of the box (CI-tested per template).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from importlib import resources
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
TEMPLATES = ("blank", "llm", "rag-qa", "tool-agent", "hitl")
|
|
19
|
+
|
|
20
|
+
# Agent names double as k8s child-resource names + queue names — keep them DNS-label-safe.
|
|
21
|
+
_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
22
|
+
|
|
23
|
+
# Template files stored dot-less and written out WITH the dot. Keep this in sync with the template
|
|
24
|
+
# dirs — a name missing here lands in the project as `env` instead of `.env`, which the runtime never
|
|
25
|
+
# reads and .gitignore never covers, so the key would end up committed.
|
|
26
|
+
_DOTFILES = frozenset({"gitignore", "env"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ScaffoldError(ValueError):
|
|
30
|
+
"""User-facing scaffold failure (bad name/template, target dir not empty)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def scaffold(name: str, template: str, dest_root: Path, workspace: str) -> Path:
|
|
34
|
+
"""Render ``template`` into ``dest_root/<name>/``; returns the project dir."""
|
|
35
|
+
if not _NAME_RE.match(name):
|
|
36
|
+
raise ScaffoldError(
|
|
37
|
+
f"agent name {name!r} must be kebab-case (lowercase letters, digits, '-'; start with a letter)"
|
|
38
|
+
)
|
|
39
|
+
if template not in TEMPLATES:
|
|
40
|
+
raise ScaffoldError(f"unknown template {template!r} — choose one of: {', '.join(TEMPLATES)}")
|
|
41
|
+
dest = dest_root / name
|
|
42
|
+
if dest.exists() and any(dest.iterdir()):
|
|
43
|
+
raise ScaffoldError(f"target directory {dest} exists and is not empty — refusing to overwrite")
|
|
44
|
+
|
|
45
|
+
pkg = name.replace("-", "_")
|
|
46
|
+
replacements = {"__AGENT_NAME__": name, "__PKG__": pkg, "__WORKSPACE__": workspace}
|
|
47
|
+
root = resources.files("keystone_cli").joinpath("templates", template)
|
|
48
|
+
|
|
49
|
+
for src in _walk(root):
|
|
50
|
+
rel = _relative_parts(src, root)
|
|
51
|
+
# `pkg/` in the template becomes the agent's real package dir. Dotfiles are stored WITHOUT
|
|
52
|
+
# the leading dot (`gitignore.tmpl`, `env.tmpl`) and renamed here — a real dotfile inside the
|
|
53
|
+
# package data vanishes from sdists/wheels, so the template would ship without it.
|
|
54
|
+
parts = [pkg if p == "pkg" else p for p in rel]
|
|
55
|
+
filename = parts[-1].removesuffix(".tmpl")
|
|
56
|
+
if filename in _DOTFILES:
|
|
57
|
+
filename = f".{filename}"
|
|
58
|
+
out = dest.joinpath(*parts[:-1], filename)
|
|
59
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
text = src.read_text(encoding="utf-8")
|
|
61
|
+
for placeholder, value in replacements.items():
|
|
62
|
+
text = text.replace(placeholder, value)
|
|
63
|
+
out.write_text(text, encoding="utf-8")
|
|
64
|
+
return dest
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _walk(node): # Traversable has no rglob — two levels is all the templates use
|
|
68
|
+
for child in node.iterdir():
|
|
69
|
+
if child.is_dir():
|
|
70
|
+
yield from _walk(child)
|
|
71
|
+
elif child.name.endswith(".tmpl"):
|
|
72
|
+
yield child
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _relative_parts(src, root) -> list[str]:
|
|
76
|
+
"""Path parts of ``src`` under ``root`` (Traversable-safe — compare by name chain)."""
|
|
77
|
+
# importlib Traversables expose no relative_to; templates are at most 2 levels deep,
|
|
78
|
+
# so rebuild the chain from the string forms (stable for both zip + dir loaders).
|
|
79
|
+
src_s, root_s = str(src), str(root)
|
|
80
|
+
rel = src_s[len(root_s) :].lstrip("/\\")
|
|
81
|
+
return rel.replace("\\", "/").split("/")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# __AGENT_NAME__
|
|
2
|
+
|
|
3
|
+
Keystone pro-code agent (scaffolded from the **blank** template).
|
|
4
|
+
|
|
5
|
+
## Local loop
|
|
6
|
+
```bash
|
|
7
|
+
keystone agent validate .
|
|
8
|
+
keystone dev . --input '{"message": "hi"}' --offline # iterate: edit → Enter → new behaviour
|
|
9
|
+
keystone agent run . --input '{"message": "hi"}' --offline # mocked building blocks
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Deploy (dev)
|
|
13
|
+
```bash
|
|
14
|
+
keystone login # once per session (device flow)
|
|
15
|
+
keystone agent deploy . --publish # bundle → server-side build → Live
|
|
16
|
+
keystone agent invoke __AGENT_NAME__ --input '{"message": "hi"}'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Next steps
|
|
20
|
+
- Real nodes: plain LangGraph (`add_node` / `add_edge` / `add_conditional_edges`).
|
|
21
|
+
- Building blocks: `from keystone.agent_sdk.clients import RAGClient, GatewayClient`.
|
|
22
|
+
- Long steps: wrap paid/side-effecting calls in `@durable_task` (crash-safe replay).
|
|
23
|
+
- Human approval: `from keystone.agent_sdk import request_approval` (HITL pause/resume).
|
|
24
|
+
- Rebuilds: agent-hub dedups identical bundles — bump the `# bundle-rev:` line in
|
|
25
|
+
`agent.yaml` to force a new image.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# bundle-rev: 0001 — bump this string to force a rebuild (agent-hub dedups identical bundles by SHA-256)
|
|
2
|
+
# __AGENT_NAME__ — scaffolded by `keystone agent init` (template: blank).
|
|
3
|
+
# Local loop:
|
|
4
|
+
# keystone agent validate .
|
|
5
|
+
# keystone agent run . --input '{"message": "hi"}' --offline
|
|
6
|
+
# Deploy (server-side build — no Dockerfile needed):
|
|
7
|
+
# keystone agent deploy . --publish
|
|
8
|
+
name: __AGENT_NAME__
|
|
9
|
+
workspace: __WORKSPACE__
|
|
10
|
+
framework: langgraph@1
|
|
11
|
+
entrypoint: __PKG__.graph:make_graph
|
|
12
|
+
runtime: python3.12
|
|
13
|
+
resources:
|
|
14
|
+
timeout_seconds: 60
|
|
15
|
+
io_schema:
|
|
16
|
+
type: object
|
|
17
|
+
properties:
|
|
18
|
+
message:
|
|
19
|
+
type: string
|
|
20
|
+
required: [message]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Local-only environment for `__AGENT_NAME__`. NEVER committed (.gitignore) and NEVER packaged —
|
|
2
|
+
# `keystone agent deploy` drops .env* unconditionally, because a bundle goes to S3 and into the
|
|
3
|
+
# agent's image in ECR, where a copy would outlive a revoked key.
|
|
4
|
+
#
|
|
5
|
+
# On the cluster the operator injects these, so leave them empty here unless you run locally.
|
|
6
|
+
|
|
7
|
+
# The agent's platform API key. Create it on the portal (Access policy → API keys) and paste the
|
|
8
|
+
# value shown ONCE. Read automatically: agent-runtime settings use env_prefix="RT_" + env_file=".env",
|
|
9
|
+
# so the RT_ prefix is required — a differently-named variable is IGNORED SILENTLY.
|
|
10
|
+
RT_AGENT_API_KEY=
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""__AGENT_NAME__ — a minimal Keystone pro-code agent.
|
|
2
|
+
|
|
3
|
+
One-node LangGraph: ``respond`` echoes the input. Authoring is PLAIN LangGraph (the SDK
|
|
4
|
+
wraps nothing); building-block clients (rag, llm-gateway) live in
|
|
5
|
+
``keystone.agent_sdk.clients`` — see the rag-qa / tool-agent templates for examples.
|
|
6
|
+
|
|
7
|
+
Local loop:
|
|
8
|
+
keystone agent validate .
|
|
9
|
+
keystone agent run . --input '{"message": "hi"}' --offline
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TypedDict
|
|
15
|
+
|
|
16
|
+
from langgraph.graph import StateGraph
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class State(TypedDict, total=False):
|
|
20
|
+
message: str
|
|
21
|
+
reply: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def respond(state: State) -> dict[str, str]:
|
|
25
|
+
return {"reply": f"__AGENT_NAME__ received: {state.get('message', '')}"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_graph():
|
|
29
|
+
g = StateGraph(State)
|
|
30
|
+
g.add_node("respond", respond)
|
|
31
|
+
g.set_entry_point("respond")
|
|
32
|
+
g.set_finish_point("respond")
|
|
33
|
+
return g
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "__AGENT_NAME__"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
# Declared for humans/IDEs: the DEPLOYED image is built FROM the platform base image,
|
|
6
|
+
# which already bakes keystone-agent-sdk + langgraph — the server-side (Kaniko) build
|
|
7
|
+
# copies your code only, it does NOT pip-install. Locally, `keystone agent validate/run`
|
|
8
|
+
# execute in the CLI's own environment.
|
|
9
|
+
dependencies = [
|
|
10
|
+
"keystone-agent-sdk>=0.3",
|
|
11
|
+
"langgraph>=1,<2",
|
|
12
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# __AGENT_NAME__
|
|
2
|
+
|
|
3
|
+
Scaffolded from the **hitl** template — a human-in-the-loop agent. It drafts a response and,
|
|
4
|
+
when the request looks high-impact, **pauses for a person to approve** before releasing it.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
__PKG__/graph.py # assess → (approve | release); approve calls request_approval(...)
|
|
8
|
+
agent.yaml # llm-gateway + a hitl: block (timeout, on_timeout); io_schema = { request }
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Run it locally
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
keystone agent validate .
|
|
15
|
+
keystone agent run . --input '{"request": "summarise today’s standup"}' --offline
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Offline the model stub classifies every request as routine, so the run completes without
|
|
19
|
+
pausing (a local run has no checkpointer to park on). The approval path is exercised on the
|
|
20
|
+
platform.
|
|
21
|
+
|
|
22
|
+
## Deploy and approve
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
keystone agent deploy . --publish
|
|
26
|
+
keystone agent invoke __AGENT_NAME__ --input '{"request": "issue a $50 refund to order 1234"}'
|
|
27
|
+
# … awaiting_human (run <id>)
|
|
28
|
+
keystone agent hitl list __AGENT_NAME__
|
|
29
|
+
keystone agent hitl respond __AGENT_NAME__ <run-id> --approve --note "ok"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Use `--reject` to send it down the declined branch instead. See `examples/getting-started.html`
|
|
33
|
+
for the full flow and the `demo-helpdesk` / `support-triage` examples for richer HITL graphs.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# bundle-rev: 0001 — bump this string to force a rebuild (agent-hub dedups identical bundles by SHA-256)
|
|
2
|
+
# __AGENT_NAME__ — scaffolded by `keystone agent init` (template: hitl).
|
|
3
|
+
# Drafts a response, then PAUSES for a human to approve high-impact requests before releasing them.
|
|
4
|
+
# Local loop (offline stub classifies low-impact → runs straight through, no pause):
|
|
5
|
+
# keystone agent validate .
|
|
6
|
+
# keystone agent run . --input '{"request": "summarise today’s standup"}' --offline
|
|
7
|
+
# Deploy (server-side build — no Dockerfile needed):
|
|
8
|
+
# keystone agent deploy . --publish
|
|
9
|
+
name: __AGENT_NAME__
|
|
10
|
+
workspace: __WORKSPACE__
|
|
11
|
+
framework: langgraph@1
|
|
12
|
+
entrypoint: __PKG__.graph:make_graph
|
|
13
|
+
runtime: python3.12
|
|
14
|
+
resources:
|
|
15
|
+
timeout_seconds: 60
|
|
16
|
+
# Human-in-the-loop: a paused run waits this long for an approver, then takes on_timeout.
|
|
17
|
+
# `reject` flows the graph's own declined branch (a dead `expired` run would be worse UX).
|
|
18
|
+
hitl:
|
|
19
|
+
timeout_seconds: 3600
|
|
20
|
+
on_timeout: reject
|
|
21
|
+
io_schema:
|
|
22
|
+
type: object
|
|
23
|
+
properties:
|
|
24
|
+
request:
|
|
25
|
+
type: string
|
|
26
|
+
required: [request]
|
|
27
|
+
dependencies:
|
|
28
|
+
services: [llm-gateway]
|
|
29
|
+
models: [bedrock/anthropic.claude-sonnet-4-6]
|
|
30
|
+
identity:
|
|
31
|
+
scopes: [gateway:chat]
|
|
32
|
+
config:
|
|
33
|
+
RT_AGENT_MODEL: "bedrock/anthropic.claude-sonnet-4-6"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Local-only environment for `__AGENT_NAME__`. NEVER committed (.gitignore) and NEVER packaged —
|
|
2
|
+
# `keystone agent deploy` drops .env* unconditionally, because a bundle goes to S3 and into the
|
|
3
|
+
# agent's image in ECR, where a copy would outlive a revoked key.
|
|
4
|
+
#
|
|
5
|
+
# On the cluster the operator injects these, so leave them empty here unless you run locally.
|
|
6
|
+
|
|
7
|
+
# The agent's platform API key. Create it on the portal (Access policy → API keys) and paste the
|
|
8
|
+
# value shown ONCE. Read automatically: agent-runtime settings use env_prefix="RT_" + env_file=".env",
|
|
9
|
+
# so the RT_ prefix is required — a differently-named variable is IGNORED SILENTLY.
|
|
10
|
+
RT_AGENT_API_KEY=
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""__AGENT_NAME__ — human-in-the-loop: draft a response, then pause for approval when it matters.
|
|
2
|
+
|
|
3
|
+
assess ──high-impact?──▶ approve ──human says yes──▶ release the draft
|
|
4
|
+
│ └──says no────▶ declined note
|
|
5
|
+
└──routine───────────────────▶ release the draft
|
|
6
|
+
|
|
7
|
+
``assess`` drafts the response AND asks the model whether the request is high-impact. A
|
|
8
|
+
**conditional edge** routes high-impact requests through ``approve``, which calls
|
|
9
|
+
``request_approval`` — the run parks as ``awaiting_human`` until a person answers with
|
|
10
|
+
``keystone agent hitl respond``. Routine requests skip straight to release.
|
|
11
|
+
|
|
12
|
+
Two rules the platform enforces for HITL (see keystone.agent_sdk.request_approval):
|
|
13
|
+
* Call ``request_approval`` EARLY in the node — on resume the whole node re-runs up to it,
|
|
14
|
+
so nothing paid/side-effecting may run before it. Here it is the first statement.
|
|
15
|
+
* Paid work goes through ``@durable_task`` so a crash-resume replays it, never re-bills it.
|
|
16
|
+
|
|
17
|
+
Offline the model stub isn't valid JSON, so ``assess`` defaults to routine and the run
|
|
18
|
+
completes without ever pausing (a local run has no checkpointer to park on).
|
|
19
|
+
|
|
20
|
+
Local loop:
|
|
21
|
+
keystone agent run . --input '{"request": "summarise today’s standup"}' --offline
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
from typing import Any, TypedDict
|
|
29
|
+
|
|
30
|
+
from keystone.agent_sdk import durable_task, request_approval
|
|
31
|
+
from keystone.agent_sdk.clients import GatewayClient
|
|
32
|
+
from langgraph.graph import StateGraph
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class State(TypedDict, total=False):
|
|
36
|
+
request: str # what the user asked the agent to do (the input)
|
|
37
|
+
draft: str # the response the agent proposes
|
|
38
|
+
needs_approval: bool # true → route through a human before releasing
|
|
39
|
+
result: str # the released response (or the declined note)
|
|
40
|
+
approved: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _gateway() -> GatewayClient:
|
|
44
|
+
return GatewayClient(
|
|
45
|
+
base_url=os.environ.get("RT_GATEWAY_URL", ""),
|
|
46
|
+
auth_token=os.environ.get("RT_GATEWAY_TOKEN", ""),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _model() -> str:
|
|
51
|
+
return os.environ.get("RT_AGENT_MODEL", "bedrock/anthropic.claude-sonnet-4-6")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@durable_task
|
|
55
|
+
async def _chat(system: str, user: str) -> str:
|
|
56
|
+
# The paid step. @durable_task: a crash-resumed run replays the persisted answer
|
|
57
|
+
# instead of paying for the model twice. Plain-data args only — client built inside.
|
|
58
|
+
result = await _gateway().chat(
|
|
59
|
+
messages=[
|
|
60
|
+
{"role": "system", "content": system},
|
|
61
|
+
{"role": "user", "content": user},
|
|
62
|
+
],
|
|
63
|
+
model=_model(),
|
|
64
|
+
)
|
|
65
|
+
return result["choices"][0]["message"]["content"] if isinstance(result, dict) else ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def assess(state: State) -> dict[str, Any]:
|
|
69
|
+
"""Draft the response, and classify whether it needs a human sign-off before release."""
|
|
70
|
+
request = state.get("request", "")
|
|
71
|
+
draft = await _chat("You are a helpful assistant. Write a short, ready-to-send response.", request)
|
|
72
|
+
raw = await _chat(
|
|
73
|
+
'Reply with ONLY {"high_impact": true|false} — true if carrying out this request could '
|
|
74
|
+
"spend money, message a customer, or change data. No prose, no code fences.",
|
|
75
|
+
request,
|
|
76
|
+
)
|
|
77
|
+
needs = False
|
|
78
|
+
try:
|
|
79
|
+
needs = bool(json.loads(raw).get("high_impact", False))
|
|
80
|
+
except (json.JSONDecodeError, TypeError):
|
|
81
|
+
pass # unparseable (or the offline stub) → treat as routine, never crash
|
|
82
|
+
return {"draft": draft, "needs_approval": needs}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _route(state: State) -> str:
|
|
86
|
+
return "approve" if state.get("needs_approval") else "release"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def approve(state: State) -> dict[str, Any]:
|
|
90
|
+
# request_approval MUST be first — the node re-runs to here on resume. The run parks as
|
|
91
|
+
# awaiting_human; the approver's dict comes back when they respond.
|
|
92
|
+
decision = request_approval(
|
|
93
|
+
{"kind": "release_approval", "request": state.get("request", ""), "draft": state.get("draft", "")}
|
|
94
|
+
)
|
|
95
|
+
approved = bool(decision.get("approved")) if isinstance(decision, dict) else False
|
|
96
|
+
if not approved:
|
|
97
|
+
note = decision.get("note", "") if isinstance(decision, dict) else ""
|
|
98
|
+
return {"result": note or "Declined by the approver.", "approved": False}
|
|
99
|
+
return {"result": state.get("draft", ""), "approved": True}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def release(state: State) -> dict[str, Any]:
|
|
103
|
+
return {"result": state.get("draft", ""), "approved": True}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def make_graph():
|
|
107
|
+
g = StateGraph(State)
|
|
108
|
+
g.add_node("assess", assess)
|
|
109
|
+
g.add_node("approve", approve)
|
|
110
|
+
g.add_node("release", release)
|
|
111
|
+
g.set_entry_point("assess")
|
|
112
|
+
g.add_conditional_edges("assess", _route, {"approve": "approve", "release": "release"})
|
|
113
|
+
g.set_finish_point("approve")
|
|
114
|
+
g.set_finish_point("release")
|
|
115
|
+
return g
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "__AGENT_NAME__"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
# Declared for humans/IDEs: the DEPLOYED image is built FROM the platform base image,
|
|
6
|
+
# which already bakes keystone-agent-sdk + langgraph — the server-side (Kaniko) build
|
|
7
|
+
# copies your code only, it does NOT pip-install. Locally, `keystone agent validate/run`
|
|
8
|
+
# execute in the CLI's own environment.
|
|
9
|
+
dependencies = [
|
|
10
|
+
"keystone-agent-sdk>=0.3",
|
|
11
|
+
"langgraph>=1,<2",
|
|
12
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# __AGENT_NAME__
|
|
2
|
+
|
|
3
|
+
Scaffolded from the **llm** template — the simplest useful agent: one node that calls the
|
|
4
|
+
model through **llm-gateway** and returns its reply. No knowledge base, no tools.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
__PKG__/graph.py # respond: GatewayClient.chat(...) → reply
|
|
8
|
+
agent.yaml # declares llm-gateway + the model; io_schema = { prompt }
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Run it locally
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
keystone agent validate .
|
|
15
|
+
keystone agent run . --input '{"prompt": "explain retries in one sentence"}' --offline
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`--offline` mocks the gateway call so you can prove the wiring with no live services or quota.
|
|
19
|
+
Drop `--offline` (after `keystone login`) to hit the real model.
|
|
20
|
+
|
|
21
|
+
## Deploy
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
keystone agent deploy . --publish
|
|
25
|
+
keystone agent invoke __AGENT_NAME__ --input '{"prompt": "..."}'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The model in `dependencies.models` must be granted to your workspace, or deploy fails preflight
|
|
29
|
+
with `422 AGENT_DEPENDENCY_NOT_GRANTED`. See `examples/getting-started.html` for the full flow.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# bundle-rev: 0001 — bump this string to force a rebuild (agent-hub dedups identical bundles by SHA-256)
|
|
2
|
+
# __AGENT_NAME__ — scaffolded by `keystone agent init` (template: llm).
|
|
3
|
+
# The simplest useful agent: one node that calls the LLM through llm-gateway and returns its reply.
|
|
4
|
+
# Local loop:
|
|
5
|
+
# keystone agent validate .
|
|
6
|
+
# keystone agent run . --input '{"prompt": "explain retries in one sentence"}' --offline
|
|
7
|
+
# Deploy (server-side build — no Dockerfile needed):
|
|
8
|
+
# keystone agent deploy . --publish
|
|
9
|
+
name: __AGENT_NAME__
|
|
10
|
+
workspace: __WORKSPACE__
|
|
11
|
+
framework: langgraph@1
|
|
12
|
+
entrypoint: __PKG__.graph:make_graph
|
|
13
|
+
runtime: python3.12
|
|
14
|
+
resources:
|
|
15
|
+
timeout_seconds: 60
|
|
16
|
+
io_schema:
|
|
17
|
+
type: object
|
|
18
|
+
properties:
|
|
19
|
+
prompt:
|
|
20
|
+
type: string
|
|
21
|
+
required: [prompt]
|
|
22
|
+
dependencies:
|
|
23
|
+
services: [llm-gateway]
|
|
24
|
+
models: [bedrock/anthropic.claude-sonnet-4-6]
|
|
25
|
+
identity:
|
|
26
|
+
scopes: [gateway:chat]
|
|
27
|
+
# Per-agent config — injected into the pod as env by the operator.
|
|
28
|
+
config:
|
|
29
|
+
RT_AGENT_MODEL: "bedrock/anthropic.claude-sonnet-4-6"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Local-only environment for `__AGENT_NAME__`. NEVER committed (.gitignore) and NEVER packaged —
|
|
2
|
+
# `keystone agent deploy` drops .env* unconditionally, because a bundle goes to S3 and into the
|
|
3
|
+
# agent's image in ECR, where a copy would outlive a revoked key.
|
|
4
|
+
#
|
|
5
|
+
# On the cluster the operator injects these, so leave them empty here unless you run locally.
|
|
6
|
+
|
|
7
|
+
# The agent's platform API key. Create it on the portal (Access policy → API keys) and paste the
|
|
8
|
+
# value shown ONCE. Read automatically: agent-runtime settings use env_prefix="RT_" + env_file=".env",
|
|
9
|
+
# so the RT_ prefix is required — a differently-named variable is IGNORED SILENTLY.
|
|
10
|
+
RT_AGENT_API_KEY=
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""__AGENT_NAME__ — a minimal llm-gateway agent: one node that calls the model.
|
|
2
|
+
|
|
3
|
+
Authoring is PLAIN LangGraph — the SDK wraps nothing. The only building block here is
|
|
4
|
+
``GatewayClient`` (``keystone.agent_sdk.clients``): every model call in the platform goes
|
|
5
|
+
through llm-gateway, which enforces the workspace's model grants and budget. Caller identity
|
|
6
|
+
(entity / user / workspace) is forwarded automatically by the runtime — the agent never sets
|
|
7
|
+
headers itself. The model + gateway URL come from ENV, injected per-agent by the operator.
|
|
8
|
+
|
|
9
|
+
Local loop:
|
|
10
|
+
keystone agent run . --input '{"prompt": "explain retries in one sentence"}' --offline
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from typing import TypedDict
|
|
17
|
+
|
|
18
|
+
from keystone.agent_sdk.clients import GatewayClient
|
|
19
|
+
from langgraph.graph import StateGraph
|
|
20
|
+
|
|
21
|
+
_SYSTEM = "You are a concise, helpful assistant. Answer in a few sentences."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class State(TypedDict, total=False):
|
|
25
|
+
prompt: str
|
|
26
|
+
reply: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _gateway() -> GatewayClient:
|
|
30
|
+
return GatewayClient(
|
|
31
|
+
base_url=os.environ.get("RT_GATEWAY_URL", ""),
|
|
32
|
+
auth_token=os.environ.get("RT_GATEWAY_TOKEN", ""),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def respond(state: State) -> dict[str, str]:
|
|
37
|
+
result = await _gateway().chat(
|
|
38
|
+
messages=[
|
|
39
|
+
{"role": "system", "content": _SYSTEM},
|
|
40
|
+
{"role": "user", "content": state.get("prompt", "")},
|
|
41
|
+
],
|
|
42
|
+
model=os.environ.get("RT_AGENT_MODEL", "bedrock/anthropic.claude-sonnet-4-6"),
|
|
43
|
+
)
|
|
44
|
+
reply = result["choices"][0]["message"]["content"] if isinstance(result, dict) else ""
|
|
45
|
+
return {"reply": reply}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_graph():
|
|
49
|
+
g = StateGraph(State)
|
|
50
|
+
g.add_node("respond", respond)
|
|
51
|
+
g.set_entry_point("respond")
|
|
52
|
+
g.set_finish_point("respond")
|
|
53
|
+
return g
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "__AGENT_NAME__"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
# Declared for humans/IDEs: the DEPLOYED image is built FROM the platform base image,
|
|
6
|
+
# which already bakes keystone-agent-sdk + langgraph — the server-side (Kaniko) build
|
|
7
|
+
# copies your code only, it does NOT pip-install. Locally, `keystone agent validate/run`
|
|
8
|
+
# execute in the CLI's own environment.
|
|
9
|
+
dependencies = [
|
|
10
|
+
"keystone-agent-sdk>=0.3",
|
|
11
|
+
"langgraph>=1,<2",
|
|
12
|
+
]
|