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.
Files changed (54) hide show
  1. keystone_cli/__init__.py +4 -0
  2. keystone_cli/__main__.py +24 -0
  3. keystone_cli/auth/__init__.py +5 -0
  4. keystone_cli/auth/device_flow.py +197 -0
  5. keystone_cli/auth/token_store.py +71 -0
  6. keystone_cli/commands/__init__.py +1 -0
  7. keystone_cli/commands/agent.py +1017 -0
  8. keystone_cli/commands/dev.py +57 -0
  9. keystone_cli/commands/login.py +207 -0
  10. keystone_cli/commands/workspace.py +87 -0
  11. keystone_cli/devloop.py +183 -0
  12. keystone_cli/platform_client.py +75 -0
  13. keystone_cli/runner.py +87 -0
  14. keystone_cli/scaffold.py +81 -0
  15. keystone_cli/templates/blank/README.md.tmpl +25 -0
  16. keystone_cli/templates/blank/agent.yaml.tmpl +20 -0
  17. keystone_cli/templates/blank/env.tmpl +10 -0
  18. keystone_cli/templates/blank/gitignore.tmpl +7 -0
  19. keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
  20. keystone_cli/templates/blank/pkg/graph.py.tmpl +33 -0
  21. keystone_cli/templates/blank/pyproject.toml.tmpl +12 -0
  22. keystone_cli/templates/hitl/README.md.tmpl +33 -0
  23. keystone_cli/templates/hitl/agent.yaml.tmpl +33 -0
  24. keystone_cli/templates/hitl/env.tmpl +10 -0
  25. keystone_cli/templates/hitl/gitignore.tmpl +7 -0
  26. keystone_cli/templates/hitl/pkg/__init__.py.tmpl +0 -0
  27. keystone_cli/templates/hitl/pkg/graph.py.tmpl +115 -0
  28. keystone_cli/templates/hitl/pyproject.toml.tmpl +12 -0
  29. keystone_cli/templates/llm/README.md.tmpl +29 -0
  30. keystone_cli/templates/llm/agent.yaml.tmpl +29 -0
  31. keystone_cli/templates/llm/env.tmpl +10 -0
  32. keystone_cli/templates/llm/gitignore.tmpl +7 -0
  33. keystone_cli/templates/llm/pkg/__init__.py.tmpl +0 -0
  34. keystone_cli/templates/llm/pkg/graph.py.tmpl +53 -0
  35. keystone_cli/templates/llm/pyproject.toml.tmpl +12 -0
  36. keystone_cli/templates/rag-qa/README.md.tmpl +23 -0
  37. keystone_cli/templates/rag-qa/agent.yaml.tmpl +34 -0
  38. keystone_cli/templates/rag-qa/env.tmpl +10 -0
  39. keystone_cli/templates/rag-qa/gitignore.tmpl +7 -0
  40. keystone_cli/templates/rag-qa/pkg/__init__.py.tmpl +0 -0
  41. keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +69 -0
  42. keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
  43. keystone_cli/templates/tool-agent/README.md.tmpl +30 -0
  44. keystone_cli/templates/tool-agent/agent.yaml.tmpl +25 -0
  45. keystone_cli/templates/tool-agent/env.tmpl +10 -0
  46. keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
  47. keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
  48. keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +89 -0
  49. keystone_cli/templates/tool-agent/pyproject.toml.tmpl +15 -0
  50. keystone_cli-0.1.0.dist-info/METADATA +13 -0
  51. keystone_cli-0.1.0.dist-info/RECORD +54 -0
  52. keystone_cli-0.1.0.dist-info/WHEEL +5 -0
  53. keystone_cli-0.1.0.dist-info/entry_points.txt +2 -0
  54. 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]
@@ -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=
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ # Secrets + local config. Also excluded from the deploy bundle unconditionally.
6
+ .env
7
+ .env.*
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=
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ # Secrets + local config. Also excluded from the deploy bundle unconditionally.
6
+ .env
7
+ .env.*
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=
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ # Secrets + local config. Also excluded from the deploy bundle unconditionally.
6
+ .env
7
+ .env.*
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
+ ]