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.
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/PKG-INFO +2 -2
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/pyproject.toml +3 -3
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/__main__.py +8 -0
- keystone_cli-0.1.27/src/keystone_cli/_stdio.py +68 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/auth/token_store.py +44 -1
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/agent.py +53 -1
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/login.py +9 -1
- keystone_cli-0.1.27/src/keystone_cli/commands/tool_credentials.py +153 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/runner.py +1 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/scaffold.py +4 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/README.md.tmpl +13 -1
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/pkg/graph.py.tmpl +8 -4
- keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming/README.md.tmpl +46 -0
- keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming/agent.yaml.tmpl +39 -0
- keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming/pkg/graph.py.tmpl +53 -0
- keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming/pyproject.toml.tmpl +13 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/rag-qa/agent.yaml.tmpl +5 -0
- keystone_cli-0.1.27/src/keystone_cli/templates/tool-agent/env.tmpl +10 -0
- keystone_cli-0.1.27/src/keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
- keystone_cli-0.1.27/src/keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +23 -15
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/tool-agent/pyproject.toml.tmpl +3 -1
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/PKG-INFO +2 -2
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/SOURCES.txt +12 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/requires.txt +1 -1
- keystone_cli-0.1.27/tests/test_config_env_mismatch.py +102 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_doctor.py +57 -4
- keystone_cli-0.1.27/tests/test_encoding.py +131 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_init.py +17 -0
- keystone_cli-0.1.27/tests/test_stdio.py +90 -0
- keystone_cli-0.1.27/tests/test_tool_credentials.py +94 -0
- keystone_cli-0.1.16/tests/test_encoding.py +0 -60
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/setup.cfg +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/__init__.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/_build_env.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/_version.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/auth/__init__.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/auth/device_flow.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/__init__.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/dev.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/kb.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/commands/workspace.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/devloop.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/platform_client.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/README.md.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/env.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/gitignore.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/blank/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/README.md.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/env.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/gitignore.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/guardrails/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/env.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/gitignore.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/README.md.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/env.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/gitignore.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/kb-builder/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/llm/README.md.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/llm/agent.yaml.tmpl +0 -0
- {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
- {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
- {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
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/llm/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/llm/pyproject.toml.tmpl +0 -0
- {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
- {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
- {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
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory/README.md.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory/agent.yaml.tmpl +0 -0
- {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
- {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
- {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
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory-service/README.md.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory-service/agent.yaml.tmpl +0 -0
- {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
- {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
- {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
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory-service/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/memory-service/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/rag-qa/README.md.tmpl +0 -0
- {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
- {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
- {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
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/rag-qa/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/skill/README.md.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/skill/agent.yaml.tmpl +0 -0
- {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
- {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
- {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
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/skill/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/skill/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/tool-agent/README.md.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/tool-agent/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/dependency_links.txt +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/entry_points.txt +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli.egg-info/top_level.txt +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_agent.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_assistant_provenance_and_flags.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_auth.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_build_progress.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_ci_token.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_deploy.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_dev.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_invoke.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_login_command.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_offline.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_platform_client.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_publish.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_retire_delete.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_runs.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_status.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_threads.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_traces.py +0 -0
- {keystone_cli-0.1.16 → keystone_cli-0.1.27}/tests/test_version.py +0 -0
- {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.
|
|
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
|
+
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
{keystone_cli-0.1.16 → keystone_cli-0.1.27}/src/keystone_cli/templates/hitl/pkg/graph.py.tmpl
RENAMED
|
@@ -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
|
-
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
* Paid work goes through ``@durable_task
|
|
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
|