keystone-cli 0.1.16__tar.gz → 0.1.27__tar.gz

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 (131) hide show
  1. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/PKG-INFO +2 -2
  2. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/pyproject.toml +3 -3
  3. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/__main__.py +8 -0
  4. keystone_cli-0.1.27/src/keystone_cli/_stdio.py +68 -0
  5. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/auth/token_store.py +44 -1
  6. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/agent.py +53 -1
  7. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/login.py +9 -1
  8. keystone_cli-0.1.27/src/keystone_cli/commands/tool_credentials.py +153 -0
  9. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/runner.py +1 -0
  10. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/scaffold.py +4 -0
  11. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/README.md.tmpl +13 -1
  12. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/pkg/graph.py.tmpl +8 -4
  13. keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming/README.md.tmpl +46 -0
  14. keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming/agent.yaml.tmpl +39 -0
  15. keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming/pkg/graph.py.tmpl +53 -0
  16. keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming/pyproject.toml.tmpl +13 -0
  17. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/rag-qa/agent.yaml.tmpl +5 -0
  18. keystone_cli-0.1.27/src/keystone_cli/templates/tool-agent/env.tmpl +10 -0
  19. keystone_cli-0.1.27/src/keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
  20. keystone_cli-0.1.27/src/keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
  21. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +23 -15
  22. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/tool-agent/pyproject.toml.tmpl +3 -1
  23. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/PKG-INFO +2 -2
  24. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/SOURCES.txt +12 -0
  25. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/requires.txt +1 -1
  26. keystone_cli-0.1.27/tests/test_config_env_mismatch.py +102 -0
  27. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_doctor.py +57 -4
  28. keystone_cli-0.1.27/tests/test_encoding.py +131 -0
  29. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_init.py +17 -0
  30. keystone_cli-0.1.27/tests/test_stdio.py +90 -0
  31. keystone_cli-0.1.27/tests/test_tool_credentials.py +94 -0
  32. keystone_cli-0.1.16/tests/test_encoding.py +0 -60
  33. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/setup.cfg +0 -0
  34. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/__init__.py +0 -0
  35. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/_build_env.py +0 -0
  36. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/_version.py +0 -0
  37. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/auth/__init__.py +0 -0
  38. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/auth/device_flow.py +0 -0
  39. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/__init__.py +0 -0
  40. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/dev.py +0 -0
  41. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/kb.py +0 -0
  42. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/workspace.py +0 -0
  43. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/devloop.py +0 -0
  44. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/platform_client.py +0 -0
  45. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/README.md.tmpl +0 -0
  46. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/agent.yaml.tmpl +0 -0
  47. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/env.tmpl +0 -0
  48. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/gitignore.tmpl +0 -0
  49. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
  50. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/pkg/graph.py.tmpl +0 -0
  51. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/pyproject.toml.tmpl +0 -0
  52. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/README.md.tmpl +0 -0
  53. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/agent.yaml.tmpl +0 -0
  54. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/env.tmpl +0 -0
  55. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/gitignore.tmpl +0 -0
  56. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/pkg/__init__.py.tmpl +0 -0
  57. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/pkg/graph.py.tmpl +0 -0
  58. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/pyproject.toml.tmpl +0 -0
  59. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/agent.yaml.tmpl +0 -0
  60. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/env.tmpl +0 -0
  61. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/gitignore.tmpl +0 -0
  62. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/pkg/__init__.py.tmpl +0 -0
  63. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/pyproject.toml.tmpl +0 -0
  64. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/README.md.tmpl +0 -0
  65. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/agent.yaml.tmpl +0 -0
  66. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/env.tmpl +0 -0
  67. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/gitignore.tmpl +0 -0
  68. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/pkg/__init__.py.tmpl +0 -0
  69. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/pkg/graph.py.tmpl +0 -0
  70. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/pyproject.toml.tmpl +0 -0
  71. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/llm/README.md.tmpl +0 -0
  72. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/llm/agent.yaml.tmpl +0 -0
  73. {keystone_cli-0.1.16/src/keystone_cli/templates/memory-service → keystone_cli-0.1.27/src/keystone_cli/templates/llm}/env.tmpl +0 -0
  74. {keystone_cli-0.1.16/src/keystone_cli/templates/memory-service → keystone_cli-0.1.27/src/keystone_cli/templates/llm}/gitignore.tmpl +0 -0
  75. {keystone_cli-0.1.16/src/keystone_cli/templates/memory-service → keystone_cli-0.1.27/src/keystone_cli/templates/llm}/pkg/__init__.py.tmpl +0 -0
  76. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/llm/pkg/graph.py.tmpl +0 -0
  77. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/llm/pyproject.toml.tmpl +0 -0
  78. {keystone_cli-0.1.16/src/keystone_cli/templates/llm → keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming}/env.tmpl +0 -0
  79. {keystone_cli-0.1.16/src/keystone_cli/templates/llm → keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming}/gitignore.tmpl +0 -0
  80. {keystone_cli-0.1.16/src/keystone_cli/templates/llm → keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming}/pkg/__init__.py.tmpl +0 -0
  81. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory/README.md.tmpl +0 -0
  82. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory/agent.yaml.tmpl +0 -0
  83. {keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.27/src/keystone_cli/templates/memory}/env.tmpl +0 -0
  84. {keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.27/src/keystone_cli/templates/memory}/gitignore.tmpl +0 -0
  85. {keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.27/src/keystone_cli/templates/memory}/pkg/__init__.py.tmpl +0 -0
  86. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory/pkg/graph.py.tmpl +0 -0
  87. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory/pyproject.toml.tmpl +0 -0
  88. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory-service/README.md.tmpl +0 -0
  89. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory-service/agent.yaml.tmpl +0 -0
  90. {keystone_cli-0.1.16/src/keystone_cli/templates/memory → keystone_cli-0.1.27/src/keystone_cli/templates/memory-service}/env.tmpl +0 -0
  91. {keystone_cli-0.1.16/src/keystone_cli/templates/memory → keystone_cli-0.1.27/src/keystone_cli/templates/memory-service}/gitignore.tmpl +0 -0
  92. {keystone_cli-0.1.16/src/keystone_cli/templates/memory → keystone_cli-0.1.27/src/keystone_cli/templates/memory-service}/pkg/__init__.py.tmpl +0 -0
  93. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory-service/pkg/graph.py.tmpl +0 -0
  94. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory-service/pyproject.toml.tmpl +0 -0
  95. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/rag-qa/README.md.tmpl +0 -0
  96. {keystone_cli-0.1.16/src/keystone_cli/templates/skill → keystone_cli-0.1.27/src/keystone_cli/templates/rag-qa}/env.tmpl +0 -0
  97. {keystone_cli-0.1.16/src/keystone_cli/templates/skill → keystone_cli-0.1.27/src/keystone_cli/templates/rag-qa}/gitignore.tmpl +0 -0
  98. {keystone_cli-0.1.16/src/keystone_cli/templates/skill → keystone_cli-0.1.27/src/keystone_cli/templates/rag-qa}/pkg/__init__.py.tmpl +0 -0
  99. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +0 -0
  100. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/rag-qa/pyproject.toml.tmpl +0 -0
  101. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/skill/README.md.tmpl +0 -0
  102. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/skill/agent.yaml.tmpl +0 -0
  103. {keystone_cli-0.1.16/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.27/src/keystone_cli/templates/skill}/env.tmpl +0 -0
  104. {keystone_cli-0.1.16/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.27/src/keystone_cli/templates/skill}/gitignore.tmpl +0 -0
  105. {keystone_cli-0.1.16/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.27/src/keystone_cli/templates/skill}/pkg/__init__.py.tmpl +0 -0
  106. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/skill/pkg/graph.py.tmpl +0 -0
  107. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/skill/pyproject.toml.tmpl +0 -0
  108. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/tool-agent/README.md.tmpl +0 -0
  109. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/tool-agent/agent.yaml.tmpl +0 -0
  110. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/dependency_links.txt +0 -0
  111. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/entry_points.txt +0 -0
  112. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/top_level.txt +0 -0
  113. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_agent.py +0 -0
  114. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_assistant_provenance_and_flags.py +0 -0
  115. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_auth.py +0 -0
  116. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_build_progress.py +0 -0
  117. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_ci_token.py +0 -0
  118. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_deploy.py +0 -0
  119. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_dev.py +0 -0
  120. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_invoke.py +0 -0
  121. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_login_command.py +0 -0
  122. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_offline.py +0 -0
  123. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_platform_client.py +0 -0
  124. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_publish.py +0 -0
  125. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_retire_delete.py +0 -0
  126. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_runs.py +0 -0
  127. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_status.py +0 -0
  128. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_threads.py +0 -0
  129. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_traces.py +0 -0
  130. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_version.py +0 -0
  131. {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_workspace.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keystone-cli
3
- Version: 0.1.16
3
+ Version: 0.1.27
4
4
  Summary: Keystone pro-code agent CLI — author, validate, and run agents locally, then deploy and invoke them on the Keystone platform.
5
5
  Requires-Python: >=3.12
6
- Requires-Dist: keystone-agent-sdk<0.6,>=0.5
6
+ Requires-Dist: keystone-agent-sdk<0.11,>=0.10
7
7
  Requires-Dist: keystone-request-context<0.4,>=0.3
8
8
  Requires-Dist: typer<1,>=0.12
9
9
  Requires-Dist: httpx<1,>=0.27
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "keystone-cli"
7
- version = "0.1.16"
7
+ version = "0.1.27"
8
8
  description = "Keystone pro-code agent CLI — author, validate, and run agents locally, then deploy and invoke them on the Keystone platform."
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
11
11
  # Pinned to a compatible range: on an index (Artifact Registry) an unpinned name resolves to
12
12
  # ANY published version — the wheel's metadata is the only pin that travels with it.
13
- "keystone-agent-sdk>=0.5,<0.6",
13
+ "keystone-agent-sdk>=0.10,<0.11",
14
14
  # Transitive (the SDK's building-block clients) + used directly by `run` to set a local
15
15
  # caller-identity ContextVar and to patch the clients for --offline. uv sources are NOT
16
16
  # transitive, so declare it directly (cf. services/llm-gateway).
@@ -45,7 +45,7 @@ where = ["src"]
45
45
  keystone_cli = ["templates/*/*.tmpl", "templates/*/pkg/*.tmpl"]
46
46
 
47
47
  [tool.ruff]
48
- target-version = "0.1.16"
48
+ target-version = "0.1.27"
49
49
  line-length = 120
50
50
 
51
51
  [tool.ruff.lint]
@@ -4,13 +4,20 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
+ from keystone_cli._stdio import make_stdio_unicode_safe
7
8
  from keystone_cli._version import version_line
8
9
  from keystone_cli.commands.agent import agent_app
9
10
  from keystone_cli.commands.dev import dev
10
11
  from keystone_cli.commands.kb import kb_app
11
12
  from keystone_cli.commands.login import login
13
+ from keystone_cli.commands.tool_credentials import tool_credentials_app
12
14
  from keystone_cli.commands.workspace import workspace_app
13
15
 
16
+ # FDP-4360 — before Typer builds anything. `--help` is rendered inside `app()`, and the help
17
+ # text comes from docstrings carrying `→` and `─`, so a hook that ran per-command would be too
18
+ # late for the very invocation QC reported first. No-op on a console that already copes.
19
+ make_stdio_unicode_safe()
20
+
14
21
  app = typer.Typer(
15
22
  help="Keystone pro-code agent CLI — author, validate, and run agents locally.",
16
23
  no_args_is_help=True,
@@ -47,6 +54,7 @@ def version() -> None:
47
54
  app.add_typer(agent_app, name="agent")
48
55
  app.add_typer(workspace_app, name="workspace")
49
56
  app.add_typer(kb_app, name="kb")
57
+ app.add_typer(tool_credentials_app, name="tool-credentials")
50
58
  app.command(name="login")(login)
51
59
  app.command(name="dev")(dev)
52
60
 
@@ -0,0 +1,68 @@
1
+ """Make stdout/stderr able to carry the CLI's own glyphs (FDP-4360).
2
+
3
+ Every command prints at least one of ``✓ ✗ → ─ ⚠ ↻``, and Typer renders ``--help`` from
4
+ docstrings that contain them too. On a console whose encoding cannot represent those — a
5
+ Windows cp1252 shell is the reported case, but any ``LANG=C`` box behaves the same — writing
6
+ them raises ``UnicodeEncodeError`` and the command dies at the moment it reports success.
7
+ QC hit exactly that: ``keystone agent init`` created the project and then crashed printing
8
+ ``✓``, so the user saw only a traceback and had no way to tell the work had been done.
9
+
10
+ The fix is deliberately conditional rather than unconditional. A stream that can already
11
+ carry the glyphs is left alone — that keeps a deliberate ``PYTHONIOENCODING`` honoured, and
12
+ keeps this from being one more global that reaches into a process it does not own.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from typing import IO, Any
19
+
20
+ #: The characters the CLI actually emits that cp1252 (and most 8-bit encodings) cannot carry.
21
+ #: Taken from the source, not guessed: ``— … • · §`` ARE representable in cp1252 and so would
22
+ #: hide the problem if the probe included only them.
23
+ _PROBE = "→✓✗─⚠↻"
24
+
25
+
26
+ def _can_carry_glyphs(stream: IO[Any]) -> bool:
27
+ encoding = getattr(stream, "encoding", None)
28
+ if not encoding:
29
+ # No declared encoding (a pipe wrapper, a test double). Nothing to prove it cannot
30
+ # cope, and nothing to reconfigure either — treat it as fine and leave it be.
31
+ return True
32
+ try:
33
+ _PROBE.encode(encoding)
34
+ except (UnicodeEncodeError, LookupError):
35
+ return False
36
+ return True
37
+
38
+
39
+ def make_stdio_unicode_safe(streams: tuple[IO[Any], ...] | None = None) -> None:
40
+ """Re-open stdout/stderr as UTF-8 when their current encoding cannot carry our glyphs.
41
+
42
+ Called once from the CLI entry point, before Typer builds anything — ``--help`` is
43
+ rendered during ``app()``, so a later hook would already be too late.
44
+
45
+ Three things are deliberately tolerated rather than raised on, because a CLI that cannot
46
+ fix its console must still run the command the user asked for:
47
+
48
+ * a stream with no ``reconfigure`` (``io.TextIOWrapper`` has it; pytest's capture object
49
+ and anything hand-rolled may not),
50
+ * ``reconfigure`` refusing — a detached or closed stream,
51
+ * UTF-8 itself being rejected, in which case the encoding is left as it is and only the
52
+ error handler is relaxed, so an unencodable glyph degrades to ``?`` instead of a crash.
53
+ """
54
+ for stream in streams if streams is not None else (sys.stdout, sys.stderr):
55
+ if _can_carry_glyphs(stream):
56
+ continue
57
+ reconfigure = getattr(stream, "reconfigure", None)
58
+ if reconfigure is None:
59
+ continue
60
+ try:
61
+ reconfigure(encoding="utf-8", errors="replace")
62
+ except (ValueError, OSError, LookupError):
63
+ # Last resort: keep the console's own encoding and stop it from raising. The
64
+ # glyphs become '?', which is a cosmetic loss; the crash was a functional one.
65
+ try:
66
+ reconfigure(errors="replace")
67
+ except (ValueError, OSError):
68
+ pass
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import os
12
+ import sys
12
13
  from dataclasses import asdict, dataclass, fields
13
14
  from pathlib import Path
14
15
 
@@ -85,6 +86,46 @@ class AuthConfig:
85
86
  env: str = ""
86
87
 
87
88
 
89
+ def endpoint_env(cfg: AuthConfig) -> str | None:
90
+ """Which env's BAKED endpoints this config carries (issuer or api_base match), or None.
91
+
92
+ None for an empty config and for custom endpoints (a deliberate ``--api-base`` / ``--issuer``
93
+ pointing somewhere no env bakes), so only a config that is recognisably ANOTHER env's is flagged.
94
+ """
95
+ for env, (issuer, api_base, _agent) in _ENV_DEFAULTS.items():
96
+ if (cfg.issuer and cfg.issuer == issuer) or (cfg.api_base and cfg.api_base.rstrip("/") == api_base):
97
+ return env
98
+ return None
99
+
100
+
101
+ def endpoints_belong_to_other_env(cfg: AuthConfig) -> str | None:
102
+ """The OTHER env whose endpoints this config carries (vs. this package's ``BUILD_ENV``), else None.
103
+
104
+ FDP-3072: the ``env`` stamp alone is not proof. A config stamped ``env=prod`` can still hold the
105
+ dev issuer/api_base (seen 2026-09-30 after installing the prod package over a dev login): the
106
+ stamp matched, so ``login`` kept the cache and every command silently went to dev.
107
+ """
108
+ env = endpoint_env(cfg)
109
+ return env if env is not None and env != BUILD_ENV else None
110
+
111
+
112
+ _warned_mismatch = False
113
+
114
+
115
+ def _warn_if_other_env(cfg: AuthConfig) -> None:
116
+ """One stderr line per process — a warning, never a block (custom endpoints are never flagged)."""
117
+ global _warned_mismatch
118
+ other = endpoints_belong_to_other_env(cfg)
119
+ if other is None or _warned_mismatch:
120
+ return
121
+ _warned_mismatch = True
122
+ print(
123
+ f"⚠ {config_path()} points at the {other} endpoints, but this is the {BUILD_ENV} CLI — "
124
+ f"commands would go to {other}. Run `keystone login` to reset it to {BUILD_ENV}.",
125
+ file=sys.stderr,
126
+ )
127
+
128
+
88
129
  def config_path() -> Path:
89
130
  """Config file path. ``KEYSTONE_CONFIG`` overrides (tests); default ``~/.keystone/config.json``."""
90
131
  override = os.environ.get("KEYSTONE_CONFIG")
@@ -100,7 +141,9 @@ def load() -> AuthConfig:
100
141
  # turned a valid session into "not logged in".
101
142
  data = json.loads(path.read_text(encoding="utf-8"))
102
143
  known = {f.name for f in fields(AuthConfig)}
103
- return AuthConfig(**{k: v for k, v in data.items() if k in known})
144
+ cfg = AuthConfig(**{k: v for k, v in data.items() if k in known})
145
+ _warn_if_other_env(cfg)
146
+ return cfg
104
147
 
105
148
 
106
149
  def _ensure_private_dir(directory: Path) -> None:
@@ -1486,6 +1486,12 @@ _GRANT_NAMESPACE = {
1486
1486
  "memory": "memory:",
1487
1487
  "skill": "skill:",
1488
1488
  }
1489
+ #: Every name agent-hub acts on (AgentManifestAclGrants): the four above plus rag, whose access is
1490
+ #: the per-KB grant instead of a service-level one. Anything else mints nothing and is not in the
1491
+ #: invocation ticket's scope either, so every call to it fails at runtime — FDP-5461: doctor used to
1492
+ #: skip such a name exactly like rag and still say "wiring looks complete".
1493
+ _KNOWN_SERVICES = frozenset(_GRANT_NAMESPACE) | {"rag"}
1494
+ _KB_QUERY = "knowledge-base:query"
1489
1495
 
1490
1496
 
1491
1497
  @agent_app.command()
@@ -1532,12 +1538,22 @@ def doctor(
1532
1538
  typer.secho(" ? no live version — publish one, then re-run.", fg=typer.colors.YELLOW)
1533
1539
  raise typer.Exit(1)
1534
1540
  declared = set(_doctor_string_list(deps, "services"))
1541
+ kbs = _doctor_string_list(deps, "knowledge_bases")
1535
1542
  agentic_kbs = _doctor_string_list(deps, "agentic_knowledge_bases")
1536
1543
  typer.echo(f" declared: {', '.join(sorted(declared)) or '(none)'}")
1537
1544
 
1545
+ problems += _doctor_check_unknown_services(declared)
1546
+ if "rag" in declared and not kbs and deps.get("manage_knowledge_bases") is not True:
1547
+ # Advice, not a failure: nothing to query yet, but the agent may be given KBs later.
1548
+ typer.secho(
1549
+ " ! rag declared with no knowledge_bases (and no manage_knowledge_bases) — "
1550
+ "every KB call will 403 until you list the KBs it may read.",
1551
+ fg=typer.colors.YELLOW,
1552
+ )
1538
1553
  entries = _doctor_acl_entries(client, base, headers, agent_id)
1539
1554
  if entries is not None:
1540
1555
  problems += _doctor_check_grants(entries, declared)
1556
+ problems += _doctor_check_kbs(entries, kbs)
1541
1557
  problems += _doctor_check_agentic_kbs(entries, agentic_kbs)
1542
1558
  if "memory" in declared:
1543
1559
  problems += _doctor_check_memory(client, base, headers, agent_id, workspace)
@@ -1626,13 +1642,49 @@ def _doctor_check_agentic_kbs(entries: list[dict], declared_agentic: list[str])
1626
1642
  return 1
1627
1643
 
1628
1644
 
1645
+ def _doctor_check_unknown_services(declared: set[str]) -> int:
1646
+ """A declared service agent-hub does not know — almost always a typo (FDP-5461)."""
1647
+ unknown = sorted(declared - _KNOWN_SERVICES)
1648
+ for svc in unknown:
1649
+ typer.secho(
1650
+ f" ✗ {svc:9} is not a building block — nothing is granted for it and every call to it "
1651
+ f"fails. Check the name (known: {', '.join(sorted(_KNOWN_SERVICES))}).",
1652
+ fg=typer.colors.RED,
1653
+ )
1654
+ return len(unknown)
1655
+
1656
+
1657
+ def _doctor_check_kbs(entries: list[dict], declared_kbs: list[str]) -> int:
1658
+ """Did `dependencies.knowledge_bases` actually mint `knowledge-base:query` per KB?
1659
+
1660
+ Counted, not matched by id, for the reason given on the agentic check below: an entry may be a
1661
+ KB NAME that agent-hub resolved at publish. A name it could not resolve is kept as-is and simply
1662
+ mints nothing, so a short count is how a misspelt or foreign KB shows up here.
1663
+ """
1664
+ if not declared_kbs:
1665
+ return 0
1666
+ granted = [r for r in entries if str(r.get("permission", "")) == _KB_QUERY]
1667
+ want = len(declared_kbs)
1668
+ if len(granted) >= want:
1669
+ typer.secho(f" ✓ rag {_KB_QUERY} on {len(granted)} KB(s)", fg=typer.colors.GREEN)
1670
+ return 0
1671
+ typer.secho(
1672
+ f" ✗ rag declares {want} KB(s) but only {len(granted)} {_KB_QUERY} grant(s) landed — "
1673
+ "an entry names a KB that does not exist in this workspace (or is misspelt). Fix "
1674
+ "knowledge_bases and re-publish.",
1675
+ fg=typer.colors.RED,
1676
+ )
1677
+ return 1
1678
+
1679
+
1629
1680
  def _doctor_check_grants(entries: list[dict], declared: set[str]) -> int:
1630
1681
  held = {str(r.get("permission", "")) for r in entries}
1631
1682
  problems = 0
1632
1683
  for svc in sorted(declared):
1633
1684
  prefix = _GRANT_NAMESPACE.get(svc)
1634
1685
  if prefix is None:
1635
- # rag is the deliberate case: its access IS the per-KB grant, not a service-level one.
1686
+ # rag: its access IS the per-KB grant (_doctor_check_kbs). An unknown name was already
1687
+ # reported by _doctor_check_unknown_services — not silently skipped any more.
1636
1688
  continue
1637
1689
  matching = sorted(p for p in held if p.startswith(prefix))
1638
1690
  if matching:
@@ -61,7 +61,15 @@ def login(
61
61
  # cached (no silent wrong-realm). Same env keeps the cache; flags still override.
62
62
  cfg = ts.load()
63
63
  same_env = (cfg.env == ts.BUILD_ENV) if cfg.env else (not cfg.issuer or cfg.issuer == ts.DEFAULT_ISSUER)
64
- if not same_env:
64
+ # FDP-3072: the stamp can match while the endpoints are another env's (env=prod holding dev URLs).
65
+ # Treat that as cross-env too, or the stale endpoints survive every login.
66
+ stale_env = ts.endpoints_belong_to_other_env(cfg)
67
+ if stale_env is not None:
68
+ typer.secho(
69
+ f" cached config pointed at the {stale_env} endpoints — resetting to the {ts.BUILD_ENV} defaults",
70
+ fg=typer.colors.YELLOW,
71
+ )
72
+ if not same_env or stale_env is not None:
65
73
  cfg = ts.AuthConfig()
66
74
  cfg.env = ts.BUILD_ENV
67
75
  cfg.issuer = issuer or cfg.issuer or ts.DEFAULT_ISSUER
@@ -0,0 +1,153 @@
1
+ """``keystone tool-credentials`` — the secrets a pro-code agent's catalogue tools authenticate with.
2
+
3
+ FDP-3087 P3. A tool credential is created here, then named in ``agent.yaml``::
4
+
5
+ dependencies:
6
+ tool_buckets: [crm_api]
7
+ tool_credentials:
8
+ crm_api: crm-prod-token
9
+
10
+ The value goes to platform once, which writes it to AWS Secrets Manager; nothing ever gives it back
11
+ — not platform, not this CLI. Only the platform's tool gateway reads it, when an agent calls a tool.
12
+
13
+ **The value is never taken from the command line.** An argument lands in shell history and in
14
+ ``ps`` output for every user on the machine; it is read from a hidden prompt, or from stdin with
15
+ ``--from-stdin`` (``pass show crm | keystone tool-credentials create crm-prod-token --from-stdin``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+
22
+ import httpx
23
+ import typer
24
+
25
+ from keystone_cli.commands.agent import _hub_auth
26
+
27
+ tool_credentials_app = typer.Typer(
28
+ help="Manage the credentials your agents' catalogue tools authenticate with.", no_args_is_help=True
29
+ )
30
+
31
+ _PATH = "/api/platform/v1/tool-credentials"
32
+
33
+
34
+ def _fail(response: httpx.Response, what: str) -> None:
35
+ try:
36
+ body = response.json()
37
+ detail = body.get("detail") or body.get("title") or body
38
+ except ValueError:
39
+ detail = response.text[:300]
40
+ typer.secho(f"✗ {what} failed ({response.status_code}): {detail}", fg=typer.colors.RED)
41
+ raise typer.Exit(1)
42
+
43
+
44
+ def _read_value(from_stdin: bool) -> str:
45
+ if from_stdin:
46
+ # One trailing newline is what `echo` / `pass show` add; anything else is the user's.
47
+ value = sys.stdin.read()
48
+ value = value[:-1] if value.endswith("\n") else value
49
+ value = value[:-1] if value.endswith("\r") else value
50
+ else:
51
+ value = typer.prompt("Value", hide_input=True, confirmation_prompt=True)
52
+ if not value.strip():
53
+ typer.secho("✗ the value is empty", fg=typer.colors.RED)
54
+ raise typer.Exit(1)
55
+ return value
56
+
57
+
58
+ def _resolve_id(client: httpx.Client, base: str, headers: dict[str, str], ref: str) -> str:
59
+ """Accept a name or an id — people remember names, the API speaks ids."""
60
+ response = client.get(f"{base}{_PATH}", headers=headers)
61
+ if response.status_code != httpx.codes.OK:
62
+ _fail(response, "lookup")
63
+ for item in response.json().get("data", []):
64
+ if ref in (item.get("id"), item.get("name")):
65
+ return str(item["id"])
66
+ typer.secho(f"✗ no tool credential named or with id {ref!r} in this workspace", fg=typer.colors.RED)
67
+ raise typer.Exit(1)
68
+
69
+
70
+ @tool_credentials_app.command("list")
71
+ def list_credentials(
72
+ api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
73
+ workspace_id: str | None = typer.Option(None, "--workspace-id"),
74
+ ) -> None:
75
+ """List the tool credentials in this workspace (names and hints — never values)."""
76
+ base, headers = _hub_auth(api_base, workspace_id)
77
+ with httpx.Client(timeout=30) as client:
78
+ response = client.get(f"{base}{_PATH}", headers=headers)
79
+ if response.status_code != httpx.codes.OK:
80
+ _fail(response, "list")
81
+ items = response.json().get("data", [])
82
+ if not items:
83
+ typer.echo("no tool credentials in this workspace")
84
+ return
85
+ typer.echo(f" {'NAME':32} {'ID':38} {'HINT':8} UPDATED")
86
+ for item in items:
87
+ hint = f"…{item['value_hint']}" if item.get("value_hint") else "—"
88
+ typer.echo(
89
+ f" {str(item.get('name', ''))[:32]:32} {item.get('id', ''):38} {hint:8} {item.get('updated_at', '')}"
90
+ )
91
+
92
+
93
+ @tool_credentials_app.command("create")
94
+ def create_credential(
95
+ name: str = typer.Argument(..., help="Unique per workspace — what agent.yaml refers to."),
96
+ description: str = typer.Option("", "--description"),
97
+ from_stdin: bool = typer.Option(False, "--from-stdin", help="Read the value from stdin instead of a prompt."),
98
+ api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
99
+ workspace_id: str | None = typer.Option(None, "--workspace-id"),
100
+ ) -> None:
101
+ """Create a tool credential. For basic auth the value is `user:password`."""
102
+ value = _read_value(from_stdin)
103
+ base, headers = _hub_auth(api_base, workspace_id)
104
+ body: dict[str, str] = {"name": name, "value": value}
105
+ if description:
106
+ body["description"] = description
107
+ with httpx.Client(timeout=30) as client:
108
+ response = client.post(f"{base}{_PATH}", headers=headers, json=body)
109
+ if response.status_code not in (httpx.codes.OK, httpx.codes.CREATED):
110
+ _fail(response, "create")
111
+ data = response.json().get("data", {})
112
+ typer.secho(f"✓ created {data.get('name')} — {data.get('id')}", fg=typer.colors.GREEN)
113
+ typer.echo(f" use it in agent.yaml: dependencies.tool_credentials.<bucket>: {data.get('name')}")
114
+
115
+
116
+ @tool_credentials_app.command("rotate")
117
+ def rotate_credential(
118
+ ref: str = typer.Argument(..., help="Name or id."),
119
+ from_stdin: bool = typer.Option(False, "--from-stdin", help="Read the new value from stdin."),
120
+ api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
121
+ workspace_id: str | None = typer.Option(None, "--workspace-id"),
122
+ ) -> None:
123
+ """Replace the value. Agents keep working and pick the new value up within a minute."""
124
+ value = _read_value(from_stdin)
125
+ base, headers = _hub_auth(api_base, workspace_id)
126
+ with httpx.Client(timeout=30) as client:
127
+ cred_id = _resolve_id(client, base, headers, ref)
128
+ response = client.put(f"{base}{_PATH}/{cred_id}/value", headers=headers, json={"value": value})
129
+ if response.status_code != httpx.codes.OK:
130
+ _fail(response, "rotate")
131
+ typer.secho(f"✓ rotated {ref} — agents use the new value within 60 s", fg=typer.colors.GREEN)
132
+
133
+
134
+ @tool_credentials_app.command("delete")
135
+ def delete_credential(
136
+ ref: str = typer.Argument(..., help="Name or id."),
137
+ force: bool = typer.Option(False, "--force", "--yes", "-y", help="Skip the confirmation."),
138
+ api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
139
+ workspace_id: str | None = typer.Option(None, "--workspace-id"),
140
+ ) -> None:
141
+ """Delete a tool credential. Agents that use it start failing those tool calls within 60 s."""
142
+ if not force:
143
+ typer.confirm(
144
+ f"Delete tool credential {ref}? Agents using it will fail their tool calls within a minute.",
145
+ abort=True,
146
+ )
147
+ base, headers = _hub_auth(api_base, workspace_id)
148
+ with httpx.Client(timeout=30) as client:
149
+ cred_id = _resolve_id(client, base, headers, ref)
150
+ response = client.delete(f"{base}{_PATH}/{cred_id}", headers=headers)
151
+ if response.status_code not in (httpx.codes.OK, httpx.codes.NO_CONTENT):
152
+ _fail(response, "delete")
153
+ typer.secho(f"✓ deleted {ref}", fg=typer.colors.GREEN)
@@ -48,6 +48,7 @@ _OFFLINE_KB: dict[str, Any] = {
48
48
  "top_k": 5,
49
49
  "similarity_threshold": 0.0,
50
50
  "reranker": "none",
51
+ "doc_routing_enabled": False,
51
52
  "chunk_count": 1,
52
53
  "file_count": 1,
53
54
  # The per-KB caps rag returns (schemas/kb.py) — precisely the values an agent would read to
@@ -19,6 +19,10 @@ from keystone.agent_sdk.manifest import validate_agent_name
19
19
  TEMPLATES = (
20
20
  "blank",
21
21
  "llm",
22
+ # FDP-5328 — `llm` with a token-streaming reply: the node calls the model through ChatKeystone,
23
+ # which is what stream_mode=messages republishes. Kept separate rather than changing `llm`, so
24
+ # the plainest template stays one client call a newcomer can read without LangChain.
25
+ "llm-streaming",
22
26
  "rag-qa",
23
27
  "tool-agent",
24
28
  "hitl",
@@ -29,5 +29,17 @@ keystone agent hitl list __AGENT_NAME__
29
29
  keystone agent hitl respond __AGENT_NAME__ <run-id> --approve --note "ok"
30
30
  ```
31
31
 
32
- Use `--reject` to send it down the declined branch instead. See `examples/getting-started.html`
32
+ Use `--reject` to send it down the declined branch instead.
33
+
34
+ ## Before you change `approve`
35
+
36
+ - **The node re-runs on resume.** Everything above `request_approval` in a node runs again when
37
+ the approver answers. Keep it the first statement, and put any paid call before it inside a
38
+ `@durable_task` so the resume replays the result instead of paying twice.
39
+ - **Never catch it.** `request_approval` pauses by raising `GraphInterrupt`, which is an
40
+ `Exception`. A `try/except Exception` around it swallows the pause: the run finishes without
41
+ waiting and the high-impact action goes through unapproved.
42
+ - **Several approvals are fine in sequence** (each one pauses, one at a time). Approvals that
43
+ would pause **in parallel** — parallel branches or `Send` fan-out — are not supported: only the
44
+ first reaches an approver. See `examples/getting-started.html`
33
45
  for the full flow and the `demo-helpdesk` / `support-triage` examples for richer HITL graphs.
@@ -9,10 +9,14 @@
9
9
  ``request_approval`` — the run parks as ``awaiting_human`` until a person answers with
10
10
  ``keystone agent hitl respond``. Routine requests skip straight to release.
11
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.
12
+ Three HITL rules (full text in keystone.agent_sdk.request_approval):
13
+ * The node body RE-RUNS from the top on every resume, so anything before
14
+ ``request_approval`` runs again. Call it EARLY — here it is the first statement.
15
+ * Paid work goes through ``@durable_task``: on a resume (or a crash-resume) it replays its
16
+ stored result instead of calling the model again, so it is billed once.
17
+ * NEVER wrap ``request_approval`` in ``try/except Exception``. It pauses by raising
18
+ ``GraphInterrupt``; a broad except swallows it, the run never waits for the approver, and
19
+ the action that needed approval happens anyway.
16
20
 
17
21
  Offline the model stub isn't valid JSON, so ``assess`` defaults to routine and the run
18
22
  completes without ever pausing (a local run has no checkpointer to park on).
@@ -0,0 +1,46 @@
1
+ # __AGENT_NAME__
2
+
3
+ Scaffolded from the **llm-streaming** template — the `llm` agent, but the reply streams **token by
4
+ token**. One node calls the model through **llm-gateway** via `ChatKeystone`.
5
+
6
+ ```
7
+ __PKG__/graph.py # respond: ChatKeystone(client=get_gateway_client()).ainvoke(...) → reply
8
+ agent.yaml # declares llm-gateway + the model; io_schema = { prompt }
9
+ ```
10
+
11
+ ## Why a separate template
12
+
13
+ `POST …/invoke/stream?stream_mode=messages` republishes the chunks a LangChain **chat model**
14
+ produces while a node runs. The `llm` template calls `GatewayClient.chat()` — plain HTTP — so its
15
+ stream carries state updates and **no tokens**. `ChatKeystone` wraps the same pooled client, so
16
+ grants, budget, identity and tracing behave exactly as in `llm`.
17
+
18
+ ## Run it locally
19
+
20
+ ```bash
21
+ keystone agent validate .
22
+ keystone agent run . --input '{"prompt": "explain retries in one sentence"}' --offline
23
+ ```
24
+
25
+ `--offline` mocks the gateway call and does not stream; it proves the wiring only.
26
+
27
+ ## Deploy, then watch it stream
28
+
29
+ ```bash
30
+ keystone agent deploy . --publish
31
+ ```
32
+
33
+ The CLI has no streaming subcommand yet, so stream with `curl` (`-N` turns off buffering — without
34
+ it the tokens arrive all at once and look like no streaming at all):
35
+
36
+ ```bash
37
+ curl -N -X POST "$AGENT_API/api/agent-service/v1/agents/__AGENT_NAME__/invoke/stream?stream_mode=messages" \
38
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
39
+ -d '{"inputs": {"prompt": "explain retries in one sentence"}}'
40
+ ```
41
+
42
+ Each `event: token` frame is one chunk. If you only see `state` frames and one final answer, the
43
+ node is not calling a chat model — check it still goes through `ChatKeystone`.
44
+
45
+ The model in `dependencies.models` must be granted to your workspace, or deploy fails preflight
46
+ with `422 AGENT_DEPENDENCY_NOT_GRANTED`.
@@ -0,0 +1,39 @@
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-streaming).
3
+ # The llm template, but the reply streams token by token: the node calls the model through
4
+ # ChatKeystone (a LangChain chat model over the gateway client), which is what LangGraph's
5
+ # stream_mode=messages republishes. Same inputs, same output, same grants as `llm`.
6
+ # Local loop:
7
+ # keystone agent validate .
8
+ # keystone agent run . --input '{"prompt": "explain retries in one sentence"}' --offline
9
+ # Deploy (server-side build — no Dockerfile needed):
10
+ # keystone agent deploy . --publish
11
+ name: __AGENT_NAME__
12
+ workspace: __WORKSPACE__
13
+ framework: langgraph@1
14
+ entrypoint: __PKG__.graph:make_graph
15
+ runtime: python3.12
16
+ resources:
17
+ timeout_seconds: 60
18
+ # Enforced at invoke (FDP-4694): a payload that breaks this is refused with 422 BEFORE a run
19
+ # starts, naming the field. Extra keys are allowed — add `additionalProperties: false` if you
20
+ # want them rejected too. Declaring nothing here means every input is accepted.
21
+ io_schema:
22
+ type: object
23
+ properties:
24
+ prompt:
25
+ type: string
26
+ required: [prompt]
27
+ dependencies:
28
+ services: [llm-gateway]
29
+ models: [bedrock/anthropic.claude-sonnet-4-6]
30
+ # Guardrails policy, applied by `await guard_input(messages=turn)` with no config= (FDP-4567).
31
+ # Declare `guardrails` under dependencies.services too, or the grant is never minted.
32
+ # guardrails:
33
+ # input: {pii: {enabled: true, on_detect: mask}}
34
+ # output: {secrets: {enabled: true, on_detect: block}}
35
+ identity:
36
+ scopes: [gateway:chat]
37
+ # Per-agent config — injected into the pod as env by the operator.
38
+ config:
39
+ RT_AGENT_MODEL: "bedrock/anthropic.claude-sonnet-4-6"
@@ -0,0 +1,53 @@
1
+ """__AGENT_NAME__ — an llm-gateway agent whose reply STREAMS token by token.
2
+
3
+ Same shape as the ``llm`` template, one difference that matters: the model is called through
4
+ ``ChatKeystone`` instead of ``GatewayClient.chat()``.
5
+
6
+ Why that is the whole trick: ``POST …/invoke/stream?stream_mode=messages`` republishes the chunks a
7
+ LangChain *chat model* produces while a node runs. A node that calls raw HTTP produces none, so the
8
+ stream carries state updates and no tokens — however fast the model answers. ``ChatKeystone`` wraps
9
+ the SAME pooled gateway client, so model grants, budget, caller identity and tracing are unchanged.
10
+
11
+ Nothing in this file streams by itself: ``ainvoke`` returns the full reply to the graph, and the
12
+ runtime taps the tokens on the way. ``invoke`` (non-streaming) keeps working exactly like ``llm``.
13
+
14
+ Local loop:
15
+ keystone agent run . --input '{"prompt": "explain retries in one sentence"}' --offline
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from typing import TypedDict
22
+
23
+ from keystone.agent_sdk import ChatKeystone
24
+ from keystone.agent_sdk.clients import get_gateway_client
25
+ from langchain_core.messages import HumanMessage, SystemMessage
26
+ from langgraph.graph import StateGraph
27
+
28
+ _SYSTEM = "You are a concise, helpful assistant. Answer in a few sentences."
29
+
30
+
31
+ class State(TypedDict, total=False):
32
+ prompt: str
33
+ reply: str
34
+
35
+
36
+ async def respond(state: State) -> dict[str, str]:
37
+ # Hand the model the pooled per-run client (get_gateway_client) rather than letting it build
38
+ # one: a client constructed per call opens a connection pool that is never closed (FDP-4567).
39
+ model = ChatKeystone(
40
+ client=get_gateway_client(),
41
+ model=os.environ.get("RT_AGENT_MODEL", "bedrock/anthropic.claude-sonnet-4-6"),
42
+ )
43
+ answer = await model.ainvoke([SystemMessage(_SYSTEM), HumanMessage(state.get("prompt", ""))])
44
+ # `.content`, not `.text`: a property in langchain-core 1.x, a deprecated method before it.
45
+ return {"reply": answer.content}
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