keystone-cli 0.1.9__tar.gz → 0.1.16__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.9 → keystone_cli-0.1.16}/PKG-INFO +2 -2
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/pyproject.toml +3 -3
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/__main__.py +2 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/auth/token_store.py +53 -5
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/commands/agent.py +313 -12
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/commands/dev.py +1 -0
- keystone_cli-0.1.16/src/keystone_cli/commands/kb.py +144 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/devloop.py +38 -6
- keystone_cli-0.1.16/src/keystone_cli/runner.py +594 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/scaffold.py +28 -7
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/agent.yaml.tmpl +3 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/guardrails/README.md.tmpl +63 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/guardrails/agent.yaml.tmpl +53 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/guardrails/pkg/graph.py.tmpl +102 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/hitl/agent.yaml.tmpl +8 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/hitl/pkg/graph.py.tmpl +5 -9
- keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder/README.md.tmpl +23 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder/agent.yaml.tmpl +45 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder/pkg/graph.py.tmpl +73 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/llm/agent.yaml.tmpl +8 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/llm/pkg/graph.py.tmpl +5 -9
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/memory/agent.yaml.tmpl +3 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/memory/env.tmpl +10 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/memory/gitignore.tmpl +7 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/memory/pkg/__init__.py.tmpl +0 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/memory/pyproject.toml.tmpl +12 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/memory-service/README.md.tmpl +70 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/memory-service/agent.yaml.tmpl +36 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/memory-service/pkg/graph.py.tmpl +98 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/memory-service/pyproject.toml.tmpl +12 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/agent.yaml.tmpl +56 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/env.tmpl +10 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/gitignore.tmpl +7 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/pkg/__init__.py.tmpl +0 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +73 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/skill/README.md.tmpl +52 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/skill/agent.yaml.tmpl +30 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/skill/env.tmpl +10 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/skill/gitignore.tmpl +7 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/skill/pkg/__init__.py.tmpl +0 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/skill/pkg/graph.py.tmpl +82 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/skill/pyproject.toml.tmpl +12 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/tool-agent/agent.yaml.tmpl +8 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/tool-agent/env.tmpl +10 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
- keystone_cli-0.1.16/src/keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +5 -5
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/PKG-INFO +2 -2
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/SOURCES.txt +32 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/requires.txt +1 -1
- keystone_cli-0.1.16/tests/test_assistant_provenance_and_flags.py +180 -0
- keystone_cli-0.1.16/tests/test_auth.py +238 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_deploy.py +116 -1
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_dev.py +1 -1
- keystone_cli-0.1.16/tests/test_doctor.py +142 -0
- keystone_cli-0.1.16/tests/test_encoding.py +60 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_init.py +48 -1
- keystone_cli-0.1.16/tests/test_offline.py +348 -0
- keystone_cli-0.1.9/src/keystone_cli/runner.py +0 -87
- keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa/agent.yaml.tmpl +0 -34
- keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +0 -69
- keystone_cli-0.1.9/tests/test_auth.py +0 -118
- keystone_cli-0.1.9/tests/test_offline.py +0 -34
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/setup.cfg +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/__init__.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/_build_env.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/_version.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/auth/__init__.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/auth/device_flow.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/commands/__init__.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/commands/login.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/commands/workspace.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/platform_client.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/README.md.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/env.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/gitignore.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/hitl → keystone_cli-0.1.16/src/keystone_cli/templates/guardrails}/env.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/hitl → keystone_cli-0.1.16/src/keystone_cli/templates/guardrails}/gitignore.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/hitl → keystone_cli-0.1.16/src/keystone_cli/templates/guardrails}/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/hitl → keystone_cli-0.1.16/src/keystone_cli/templates/guardrails}/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/hitl/README.md.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/llm → keystone_cli-0.1.16/src/keystone_cli/templates/hitl}/env.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/llm → keystone_cli-0.1.16/src/keystone_cli/templates/hitl}/gitignore.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/llm → keystone_cli-0.1.16/src/keystone_cli/templates/hitl}/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/llm → keystone_cli-0.1.16/src/keystone_cli/templates/hitl}/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/memory → keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder}/env.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/memory → keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder}/gitignore.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/memory → keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder}/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/memory → keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder}/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/llm/README.md.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.16/src/keystone_cli/templates/llm}/env.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.16/src/keystone_cli/templates/llm}/gitignore.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.16/src/keystone_cli/templates/llm}/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.16/src/keystone_cli/templates/llm}/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/memory/README.md.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/memory/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.16/src/keystone_cli/templates/memory-service}/env.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.16/src/keystone_cli/templates/memory-service}/gitignore.tmpl +0 -0
- {keystone_cli-0.1.9/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.16/src/keystone_cli/templates/memory-service}/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/rag-qa/README.md.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/tool-agent/README.md.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/tool-agent/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/dependency_links.txt +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/entry_points.txt +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/top_level.txt +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_agent.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_build_progress.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_ci_token.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_invoke.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_login_command.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_platform_client.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_publish.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_retire_delete.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_runs.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_status.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_threads.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_traces.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_version.py +0 -0
- {keystone_cli-0.1.9 → keystone_cli-0.1.16}/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.16
|
|
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.6,>=0.5
|
|
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.16"
|
|
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.5,<0.6",
|
|
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 = "
|
|
48
|
+
target-version = "0.1.16"
|
|
49
49
|
line-length = 120
|
|
50
50
|
|
|
51
51
|
[tool.ruff.lint]
|
|
@@ -7,6 +7,7 @@ import typer
|
|
|
7
7
|
from keystone_cli._version import version_line
|
|
8
8
|
from keystone_cli.commands.agent import agent_app
|
|
9
9
|
from keystone_cli.commands.dev import dev
|
|
10
|
+
from keystone_cli.commands.kb import kb_app
|
|
10
11
|
from keystone_cli.commands.login import login
|
|
11
12
|
from keystone_cli.commands.workspace import workspace_app
|
|
12
13
|
|
|
@@ -45,6 +46,7 @@ def version() -> None:
|
|
|
45
46
|
|
|
46
47
|
app.add_typer(agent_app, name="agent")
|
|
47
48
|
app.add_typer(workspace_app, name="workspace")
|
|
49
|
+
app.add_typer(kb_app, name="kb")
|
|
48
50
|
app.command(name="login")(login)
|
|
49
51
|
app.command(name="dev")(dev)
|
|
50
52
|
|
|
@@ -95,14 +95,62 @@ def load() -> AuthConfig:
|
|
|
95
95
|
path = config_path()
|
|
96
96
|
if not path.exists():
|
|
97
97
|
return AuthConfig()
|
|
98
|
-
|
|
98
|
+
# FDP-4716: `save()` below writes this file with encoding="utf-8"; reading it back with the
|
|
99
|
+
# locale encoding made the two disagree on Windows, where a workspace name with diacritics
|
|
100
|
+
# turned a valid session into "not logged in".
|
|
101
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
99
102
|
known = {f.name for f in fields(AuthConfig)}
|
|
100
103
|
return AuthConfig(**{k: v for k, v in data.items() if k in known})
|
|
101
104
|
|
|
102
105
|
|
|
106
|
+
def _ensure_private_dir(directory: Path) -> None:
|
|
107
|
+
"""Config dir, owner-only. ``mode=`` is IGNORED when the directory already exists, so the chmod
|
|
108
|
+
is not redundant — it is the only thing that narrows a ``~/.keystone`` an older CLI left at 0755.
|
|
109
|
+
|
|
110
|
+
Best-effort on purpose: on a shared runner the directory can belong to someone else, and failing
|
|
111
|
+
the whole ``login`` over a directory bit would be worse than the bit. The FILE is the credential
|
|
112
|
+
and its mode is enforced unconditionally below; this is the layer around it.
|
|
113
|
+
"""
|
|
114
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
115
|
+
try:
|
|
116
|
+
os.chmod(directory, 0o700)
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
103
121
|
def save(cfg: AuthConfig) -> None:
|
|
122
|
+
"""Persist the config, with the credential never once readable by anyone else (FDP-4574).
|
|
123
|
+
|
|
124
|
+
This used to be ``write_text()`` then ``chmod(0o600)``. The end state was right, but between the
|
|
125
|
+
two calls the refresh token sat on disk at whatever the umask allowed — 0644 on a default box —
|
|
126
|
+
so on a shared host or CI runner any local user could read it and mint access tokens as the
|
|
127
|
+
developer until it was revoked. Applying permissions AFTER the content is the bug; the window is
|
|
128
|
+
the vulnerability, not the final mode, which is why the existing 0600 assertion never saw it.
|
|
129
|
+
|
|
130
|
+
So the bytes only ever land in a file that was created 0600. ``O_EXCL`` (after clearing a stale
|
|
131
|
+
temp) guarantees WE created it, so ``O_CREAT``'s mode really applies rather than being inherited
|
|
132
|
+
from a leftover file — the one way this shape can silently degrade. umask can only remove bits,
|
|
133
|
+
never add them, so a permissive umask cannot widen it.
|
|
134
|
+
|
|
135
|
+
``os.replace`` then swaps the inode in atomically. Two things fall out for free: a crash can no
|
|
136
|
+
longer leave a half-written config that every later command fails to parse, and an existing
|
|
137
|
+
world-readable ``config.json`` from an older CLI is REPLACED rather than rewritten in place, so
|
|
138
|
+
it is fixed on the next save instead of keeping its old mode forever.
|
|
139
|
+
|
|
140
|
+
The temp carries the pid so two CLI processes saving at once (a `dev` watcher plus a command in
|
|
141
|
+
another terminal) cannot collide on ``O_EXCL``, and it is removed in a ``finally`` so a failed
|
|
142
|
+
save does not leave a token sitting in an orphan file.
|
|
143
|
+
"""
|
|
104
144
|
path = config_path()
|
|
105
|
-
path.parent
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
145
|
+
_ensure_private_dir(path.parent)
|
|
146
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
147
|
+
tmp.unlink(missing_ok=True)
|
|
148
|
+
# O_BINARY (Windows only, 0 elsewhere) keeps newline handling in the text wrapper alone, the way
|
|
149
|
+
# open() does it — without it the CRT would translate as well, on top of the wrapper.
|
|
150
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0)
|
|
151
|
+
try:
|
|
152
|
+
with os.fdopen(os.open(tmp, flags, 0o600), "w", encoding="utf-8") as fh:
|
|
153
|
+
fh.write(json.dumps(asdict(cfg), indent=2))
|
|
154
|
+
os.replace(tmp, path)
|
|
155
|
+
finally:
|
|
156
|
+
tmp.unlink(missing_ok=True)
|
|
@@ -77,6 +77,11 @@ def validate(
|
|
|
77
77
|
) -> None:
|
|
78
78
|
"""Validate an agent.yaml (manifest schema + entrypoint importable). Exit 1 on errors."""
|
|
79
79
|
result = sdk_validate(_manifest_path(path))
|
|
80
|
+
# Notes print on BOTH paths and never change the exit code — they are things that are valid but
|
|
81
|
+
# will not behave as the author expects (a declared block that needs a second step). Printing
|
|
82
|
+
# them only on success would hide them exactly when someone is already debugging.
|
|
83
|
+
for note in result.notes:
|
|
84
|
+
typer.secho(f"NOTE {note}", fg=typer.colors.YELLOW)
|
|
80
85
|
if result.ok:
|
|
81
86
|
typer.secho(f"✓ {path} is valid", fg=typer.colors.GREEN)
|
|
82
87
|
return
|
|
@@ -112,6 +117,7 @@ def run(
|
|
|
112
117
|
project_dir=Path(manifest_path).resolve().parent,
|
|
113
118
|
inputs=inputs,
|
|
114
119
|
offline=offline,
|
|
120
|
+
config=result.manifest.config,
|
|
115
121
|
)
|
|
116
122
|
typer.secho(f"\n✓ result: {json.dumps(final, default=str)}", fg=typer.colors.GREEN)
|
|
117
123
|
|
|
@@ -151,6 +157,12 @@ def deploy(
|
|
|
151
157
|
wait_timeout: int = typer.Option(
|
|
152
158
|
180, "--wait-timeout", help="Seconds to wait for the pod to become ready under --wait."
|
|
153
159
|
),
|
|
160
|
+
allow_secrets: bool = typer.Option(
|
|
161
|
+
False,
|
|
162
|
+
"--allow-secrets",
|
|
163
|
+
help="Package files that name a secret-looking variable anyway (fixtures, doc examples). "
|
|
164
|
+
"Does NOT override the hard block on an actual credential.",
|
|
165
|
+
),
|
|
154
166
|
) -> None:
|
|
155
167
|
"""Package the project + upload to agent-hub for a server-side build. Requires `keystone login`.
|
|
156
168
|
|
|
@@ -172,11 +184,28 @@ def deploy(
|
|
|
172
184
|
except SecretInBundleError as exc:
|
|
173
185
|
# Fail the deploy, don't warn-and-continue: once the bundle is uploaded the key is in S3 and
|
|
174
186
|
# in the image, and "we'll rotate it later" is not something a build step gets to decide.
|
|
187
|
+
# No flag lifts this one — the patterns it fires on are a credential and nothing else.
|
|
175
188
|
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
176
189
|
raise typer.Exit(1) from exc
|
|
177
190
|
except (NotADirectoryError, FileNotFoundError) as exc:
|
|
178
191
|
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
179
192
|
raise typer.Exit(1) from exc
|
|
193
|
+
# The softer tier (FDP-4715). Matched on the variable NAME, so it can be a fixture or a doc
|
|
194
|
+
# example — but the default has to be "stop", because the cost is asymmetric: a needless
|
|
195
|
+
# --allow-secrets costs one re-run, an uploaded credential costs a rotation plus a
|
|
196
|
+
# blast-radius review, and the bundle outlives the agent (retire/delete cleanup is still off).
|
|
197
|
+
if bundle.secret_warnings and not allow_secrets:
|
|
198
|
+
for warning in bundle.secret_warnings:
|
|
199
|
+
typer.secho(f"✗ {warning.describe()}", fg=typer.colors.RED)
|
|
200
|
+
typer.secho(
|
|
201
|
+
"Not packaging these. Move the value to .env (never bundled) and let the operator "
|
|
202
|
+
"inject it, or reference it from agent.yaml `config:` — re-run with --allow-secrets if "
|
|
203
|
+
"it really is a placeholder or a fixture.",
|
|
204
|
+
fg=typer.colors.RED,
|
|
205
|
+
)
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
for warning in bundle.secret_warnings:
|
|
208
|
+
typer.secho(f"⚠ {warning.describe()} — packaged anyway (--allow-secrets).", fg=typer.colors.YELLOW)
|
|
180
209
|
# Where this bundle came from. Recorded because the bundle is the WORKING DIRECTORY, not a git
|
|
181
210
|
# ref: without it, `bundle_sha256` proves two uploads were identical but never says which commit
|
|
182
211
|
# produced them — and says nothing at all if the tree had uncommitted edits.
|
|
@@ -437,19 +466,39 @@ _WARMING_MAX_ATTEMPTS = 6
|
|
|
437
466
|
_WAIT_TRANSIENT_STATUSES = ("queued", "running", "cancelling", "warming")
|
|
438
467
|
|
|
439
468
|
|
|
469
|
+
def _assistant_note(body: dict) -> str:
|
|
470
|
+
"""`· assistant <name>` when the run had one — FDP-4717 (2).
|
|
471
|
+
|
|
472
|
+
The runtime has recorded this since W1 (``agent_sessions.assistant_id`` / ``assistant_name``,
|
|
473
|
+
commented there as "metadata for run list/detail") and the API returns it on BOTH the list rows
|
|
474
|
+
and the detail read. Only the CLI never showed it, so from a terminal there was no way to tell
|
|
475
|
+
which assistant drove a run — which is most of the point of having assistants.
|
|
476
|
+
|
|
477
|
+
Falls back to the id when the name is absent (an assistant deleted after the run, say), and
|
|
478
|
+
returns "" when there is neither, so a plain run's output is byte-identical to before.
|
|
479
|
+
"""
|
|
480
|
+
label = body.get("assistant_name") or body.get("assistant_id")
|
|
481
|
+
return f" · assistant {label}" if label else ""
|
|
482
|
+
|
|
483
|
+
|
|
440
484
|
def _print_run_outcome(agent: str, body: dict) -> None:
|
|
441
485
|
"""Shared outcome printer (FDP-3332): pending statuses are NOT failures — a queued or
|
|
442
486
|
HITL-paused run exits 0 with the handle to poll/respond with."""
|
|
443
487
|
status = body.get("status", "?")
|
|
444
488
|
run_id = body.get("run_id") or body.get("session_id", "?")
|
|
445
489
|
if status in _FAILURE_STATUSES:
|
|
446
|
-
typer.secho(
|
|
490
|
+
typer.secho(
|
|
491
|
+
f"✗ {agent} — {status}: {body.get('error') or 'no result'}{_assistant_note(body)}", fg=typer.colors.RED
|
|
492
|
+
)
|
|
447
493
|
raise typer.Exit(1)
|
|
448
494
|
if status in _PENDING_STATUSES:
|
|
449
|
-
typer.secho(f"… {agent} — {status} (run {run_id})", fg=typer.colors.YELLOW)
|
|
495
|
+
typer.secho(f"… {agent} — {status} (run {run_id}){_assistant_note(body)}", fg=typer.colors.YELLOW)
|
|
450
496
|
typer.echo(f" poll with: keystone agent runs get {agent} {run_id} --wait")
|
|
451
497
|
return
|
|
452
|
-
typer.secho(
|
|
498
|
+
typer.secho(
|
|
499
|
+
f"✓ {agent} — {status} (session {body.get('session_id', run_id)}){_assistant_note(body)}",
|
|
500
|
+
fg=typer.colors.GREEN,
|
|
501
|
+
)
|
|
453
502
|
typer.echo(json.dumps(body.get("result"), indent=2, default=str, ensure_ascii=False))
|
|
454
503
|
|
|
455
504
|
|
|
@@ -661,11 +710,21 @@ def runs_list(
|
|
|
661
710
|
if not items:
|
|
662
711
|
typer.echo(f"no runs for {agent}.")
|
|
663
712
|
return
|
|
664
|
-
|
|
665
|
-
|
|
713
|
+
# FDP-4717 (2): ASSISTANT. Width from the data so a long assistant name does not shove CREATED
|
|
714
|
+
# off the line, and the column is DROPPED entirely when no run in the page had one — an agent
|
|
715
|
+
# that never uses assistants should not pay a column of dashes for the feature.
|
|
716
|
+
names = [str(it.get("assistant_name") or it.get("assistant_id") or "") for it in items]
|
|
717
|
+
awidth = max((len(n) for n in names), default=0)
|
|
718
|
+
if awidth:
|
|
719
|
+
awidth = max(awidth, len("ASSISTANT"))
|
|
720
|
+
typer.echo(f" {'RUN_ID':<36} {'STATUS':<14} {'VER':<5} {'ASSISTANT':<{awidth}} CREATED")
|
|
721
|
+
else:
|
|
722
|
+
typer.echo(f" {'RUN_ID':<36} {'STATUS':<14} {'VER':<5} CREATED")
|
|
723
|
+
for it, name in zip(items, names, strict=True):
|
|
724
|
+
assistant = f"{(name or '-'):<{awidth}} " if awidth else ""
|
|
666
725
|
typer.echo(
|
|
667
726
|
f" {it.get('run_id', ''):<36} {it.get('status', ''):<14} "
|
|
668
|
-
f"{(it.get('agent_version') or '-'):<5} {it.get('created_at') or ''}"
|
|
727
|
+
f"{(it.get('agent_version') or '-'):<5} {assistant}{it.get('created_at') or ''}"
|
|
669
728
|
)
|
|
670
729
|
typer.echo(f"\n showing {len(items)} of {total} (offset {offset})")
|
|
671
730
|
|
|
@@ -731,7 +790,10 @@ def _export_trace(
|
|
|
731
790
|
trace = (resp.json() or {}).get("data", {})
|
|
732
791
|
payload = json.dumps(trace, indent=2, ensure_ascii=False)
|
|
733
792
|
if output:
|
|
734
|
-
|
|
793
|
+
# FDP-4716: `ensure_ascii=False` above means the payload carries the real characters, so
|
|
794
|
+
# the write has to name the encoding — otherwise saving a trace with Vietnamese text
|
|
795
|
+
# raises UnicodeEncodeError on Windows.
|
|
796
|
+
Path(output).write_text(payload, encoding="utf-8")
|
|
735
797
|
typer.secho(f"✓ wrote trace {trace_id} → {output} ({len(payload)} bytes)", fg=typer.colors.GREEN)
|
|
736
798
|
return
|
|
737
799
|
typer.echo(payload)
|
|
@@ -1071,7 +1133,10 @@ def threads_history(
|
|
|
1071
1133
|
def threads_delete(
|
|
1072
1134
|
agent: str = typer.Argument(..., help="Deployed agent name."),
|
|
1073
1135
|
thread_id: str = typer.Argument(..., help="Thread id = the conversation's session_id (its root run_id)."),
|
|
1074
|
-
|
|
1136
|
+
# FDP-4717 (3): `--force` is an alias, not a second flag. `retire`/`delete` took only
|
|
1137
|
+
# `--force` and this took only `--yes`/`-y`, so a CI script had to remember which verb used
|
|
1138
|
+
# which — and `threads delete --force` failed with `No such option`. All three now accept both.
|
|
1139
|
+
yes: bool = typer.Option(False, "--yes", "-y", "--force", help="Skip the confirmation prompt (CI)."),
|
|
1075
1140
|
api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
|
|
1076
1141
|
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1077
1142
|
) -> None:
|
|
@@ -1287,6 +1352,12 @@ def _warm_up_until_ready(gw_base: str, headers: dict[str, str], agent: str, *, t
|
|
|
1287
1352
|
``502 GW_UPSTREAM_UNAVAILABLE`` past the deadline means it never came up (most often a boot crash
|
|
1288
1353
|
— CrashLoopBackOff / CreateContainerError). Exits 1 with the reason on timeout."""
|
|
1289
1354
|
url = f"{gw_base}/api/agent-service/v1/agents/{agent}/runs"
|
|
1355
|
+
# The gateway requires X-Correlation-ID on every proxied request (agent-gateway identity.py); the
|
|
1356
|
+
# edge injects X-Entity-ID/X-User-ID from the JWT but NOT the correlation id — the caller sets it,
|
|
1357
|
+
# like every other data-plane call (invoke / runs). Reusing the publish headers alone omitted it,
|
|
1358
|
+
# so the probe got 400 GW_MISSING_CORRELATION_ID, which the loop below mistook for "still warming"
|
|
1359
|
+
# and waited out to the deadline → a false "did not become Running".
|
|
1360
|
+
headers = {**headers, "X-Correlation-ID": str(uuid.uuid4())}
|
|
1290
1361
|
deadline = time.monotonic() + timeout_s
|
|
1291
1362
|
progress = _BuildProgress(sys.stdout.isatty(), label="Warming up ", noun="warm")
|
|
1292
1363
|
last = "starting"
|
|
@@ -1311,7 +1382,20 @@ def _warm_up_until_ready(gw_base: str, headers: dict[str, str], agent: str, *, t
|
|
|
1311
1382
|
last = "upstream unavailable"
|
|
1312
1383
|
progress.tick("upstream unavailable")
|
|
1313
1384
|
time.sleep(5)
|
|
1314
|
-
|
|
1385
|
+
elif resp.status_code == 400:
|
|
1386
|
+
# A rejected request (missing / invalid header) never turns into a 200 by waiting —
|
|
1387
|
+
# fail fast and name it, so it is not mistaken for a boot crash or silently waited out.
|
|
1388
|
+
progress.close()
|
|
1389
|
+
typer.secho(f"✗ warm-up probe rejected: HTTP 400 {resp.text[:200]}", fg=typer.colors.RED)
|
|
1390
|
+
typer.secho(
|
|
1391
|
+
" This is a gateway request-validation error, not a pod boot failure — the "
|
|
1392
|
+
"published version is likely serving. Verify with `keystone agent status` / "
|
|
1393
|
+
"`keystone agent invoke`; if it persists, the agent-service edge is dropping an "
|
|
1394
|
+
"identity header.",
|
|
1395
|
+
fg=typer.colors.RED,
|
|
1396
|
+
)
|
|
1397
|
+
raise typer.Exit(1)
|
|
1398
|
+
else: # e.g. 404 route not learned yet / 403 authz still propagating — keep waiting briefly
|
|
1315
1399
|
last = f"HTTP {resp.status_code}"
|
|
1316
1400
|
progress.tick(last)
|
|
1317
1401
|
time.sleep(5)
|
|
@@ -1386,6 +1470,201 @@ def publish(
|
|
|
1386
1470
|
raise typer.Exit(1) from exc
|
|
1387
1471
|
|
|
1388
1472
|
|
|
1473
|
+
#: service declared in `dependencies.services` -> the permission NAMESPACE its grants live under.
|
|
1474
|
+
#: Checked by prefix, not by an exact permission list, ON PURPOSE: the authoritative list is
|
|
1475
|
+
#: AgentManifestAclGrants (Java), and a second copy here would drift the first time someone adds a
|
|
1476
|
+
#: permission. "Is there any memory:* grant?" stays true across that change; "is memory:search
|
|
1477
|
+
#: present?" would not.
|
|
1478
|
+
#: rag's agentic endpoints are gated on this, separately from plain `knowledge-base:query`. It is
|
|
1479
|
+
#: absent from _GRANT_NAMESPACE because rag has no workspace-level grant at all — this one is
|
|
1480
|
+
#: per-KB, and is checked against `dependencies.agentic_knowledge_bases` instead.
|
|
1481
|
+
_KB_QUERY_AGENTIC = "knowledge-base:query:agentic"
|
|
1482
|
+
|
|
1483
|
+
_GRANT_NAMESPACE = {
|
|
1484
|
+
"llm-gateway": "llm:",
|
|
1485
|
+
"guardrails": "guardrails:",
|
|
1486
|
+
"memory": "memory:",
|
|
1487
|
+
"skill": "skill:",
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
@agent_app.command()
|
|
1492
|
+
def doctor(
|
|
1493
|
+
agent: str = typer.Argument(..., help="Agent name, e.g. docs-qa."),
|
|
1494
|
+
api_base: str | None = typer.Option(
|
|
1495
|
+
None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong)."
|
|
1496
|
+
),
|
|
1497
|
+
workspace_id: str | None = typer.Option(
|
|
1498
|
+
None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
|
|
1499
|
+
),
|
|
1500
|
+
) -> None:
|
|
1501
|
+
"""Check a deployed agent is actually wired up — grants minted, memory registered.
|
|
1502
|
+
|
|
1503
|
+
Declaring a building block is necessary but not always sufficient, and the gaps only show up
|
|
1504
|
+
as a 403 or 404 on the first real call. This asks the control plane the same questions up
|
|
1505
|
+
front: did agent-hub mint a grant for every service the manifest declares, and (for memory)
|
|
1506
|
+
does the agent have the tenant row that the grant alone does not create.
|
|
1507
|
+
"""
|
|
1508
|
+
cfg = ts.load()
|
|
1509
|
+
base = (api_base or cfg.api_base or ts.DEFAULT_API_BASE).rstrip("/")
|
|
1510
|
+
workspace = workspace_id or cfg.workspace_id
|
|
1511
|
+
if not workspace:
|
|
1512
|
+
typer.secho(
|
|
1513
|
+
"✗ no workspace selected — run `keystone workspace use <name|id>` (or pass --workspace-id).",
|
|
1514
|
+
fg=typer.colors.RED,
|
|
1515
|
+
)
|
|
1516
|
+
raise typer.Exit(1)
|
|
1517
|
+
try:
|
|
1518
|
+
token = df.get_access_token(cfg, save=ts.save)
|
|
1519
|
+
except df.DeviceFlowError as exc:
|
|
1520
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
1521
|
+
raise typer.Exit(1) from exc
|
|
1522
|
+
|
|
1523
|
+
headers = {"Authorization": f"Bearer {token}", "X-Workspace-ID": workspace}
|
|
1524
|
+
problems = 0
|
|
1525
|
+
try:
|
|
1526
|
+
with httpx.Client(timeout=60) as client:
|
|
1527
|
+
agent_id = _resolve_agent_id(client, base, headers, agent)
|
|
1528
|
+
typer.echo(f"{agent} ({agent_id})")
|
|
1529
|
+
|
|
1530
|
+
deps = _doctor_live_dependencies(client, base, headers, agent_id)
|
|
1531
|
+
if deps is None:
|
|
1532
|
+
typer.secho(" ? no live version — publish one, then re-run.", fg=typer.colors.YELLOW)
|
|
1533
|
+
raise typer.Exit(1)
|
|
1534
|
+
declared = set(_doctor_string_list(deps, "services"))
|
|
1535
|
+
agentic_kbs = _doctor_string_list(deps, "agentic_knowledge_bases")
|
|
1536
|
+
typer.echo(f" declared: {', '.join(sorted(declared)) or '(none)'}")
|
|
1537
|
+
|
|
1538
|
+
entries = _doctor_acl_entries(client, base, headers, agent_id)
|
|
1539
|
+
if entries is not None:
|
|
1540
|
+
problems += _doctor_check_grants(entries, declared)
|
|
1541
|
+
problems += _doctor_check_agentic_kbs(entries, agentic_kbs)
|
|
1542
|
+
if "memory" in declared:
|
|
1543
|
+
problems += _doctor_check_memory(client, base, headers, agent_id, workspace)
|
|
1544
|
+
if "skill" in declared:
|
|
1545
|
+
# Not a failure: a workspace grant is all agent-hub can mint, by decision.
|
|
1546
|
+
typer.secho(
|
|
1547
|
+
" ! skill search/list work from the workspace grant. Content routes "
|
|
1548
|
+
"(get/skill-md/files/read/grep/glob) are checked per-skill — if one 403s, "
|
|
1549
|
+
"grant that skill explicitly. Grants are on-behalf-of, so the invoking "
|
|
1550
|
+
"user's rights count too.",
|
|
1551
|
+
fg=typer.colors.YELLOW,
|
|
1552
|
+
)
|
|
1553
|
+
except httpx.RequestError as exc:
|
|
1554
|
+
typer.secho(f"✗ doctor failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
1555
|
+
raise typer.Exit(1) from exc
|
|
1556
|
+
|
|
1557
|
+
if problems:
|
|
1558
|
+
typer.secho(f"\n✗ {problems} problem(s) — the agent will fail on those at runtime.", fg=typer.colors.RED)
|
|
1559
|
+
raise typer.Exit(1)
|
|
1560
|
+
typer.secho("\n✓ wiring looks complete", fg=typer.colors.GREEN)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _doctor_live_dependencies(client: httpx.Client, base: str, headers: dict, agent_id: str) -> dict | None:
|
|
1564
|
+
"""`dependencies` of the LIVE version — what the agent is actually running, not your checkout."""
|
|
1565
|
+
resp = client.get(
|
|
1566
|
+
f"{base}/api/agent-hub/v1/agents/{agent_id}/versions",
|
|
1567
|
+
headers=headers,
|
|
1568
|
+
params={"limit": 100, "sort_by": "created_at", "sort_order": "desc"},
|
|
1569
|
+
)
|
|
1570
|
+
if resp.status_code != 200:
|
|
1571
|
+
return None
|
|
1572
|
+
live = next((v for v in resp.json().get("data", []) if v.get("status") == "live"), None)
|
|
1573
|
+
if live is None:
|
|
1574
|
+
return None
|
|
1575
|
+
manifest = live.get("manifest") or {}
|
|
1576
|
+
return manifest.get("dependencies") or {}
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _doctor_string_list(deps: dict, key: str) -> list[str]:
|
|
1580
|
+
return [str(x) for x in (deps.get(key) or []) if isinstance(x, str)]
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def _doctor_acl_entries(client: httpx.Client, base: str, headers: dict, agent_id: str) -> list[dict] | None:
|
|
1584
|
+
"""The agent's ACL entries, fetched once and shared by every grant check below."""
|
|
1585
|
+
resp = client.get(
|
|
1586
|
+
f"{base}/api/platform/v1/acl-entries",
|
|
1587
|
+
headers=headers,
|
|
1588
|
+
params={"subject_type": "agent", "subject_id": agent_id, "limit": 100},
|
|
1589
|
+
)
|
|
1590
|
+
if resp.status_code != 200:
|
|
1591
|
+
typer.secho(f" ? grants could not read acl-entries ({resp.status_code})", fg=typer.colors.YELLOW)
|
|
1592
|
+
return None
|
|
1593
|
+
return [r for r in resp.json().get("data", []) if isinstance(r, dict)]
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def _doctor_check_agentic_kbs(entries: list[dict], declared_agentic: list[str]) -> int:
|
|
1597
|
+
"""Did `dependencies.agentic_knowledge_bases` actually mint `knowledge-base:query:agentic`?
|
|
1598
|
+
|
|
1599
|
+
Counted rather than matched by id on purpose: the manifest may declare a KB by NAME, and
|
|
1600
|
+
agent-hub resolves it at publish, so the name in the manifest and the resource_id on the grant
|
|
1601
|
+
are legitimately different strings. A count mismatch is still conclusive enough to be worth
|
|
1602
|
+
reporting — nothing else mints this permission.
|
|
1603
|
+
"""
|
|
1604
|
+
if not declared_agentic:
|
|
1605
|
+
return 0
|
|
1606
|
+
granted = [r for r in entries if str(r.get("permission", "")) == _KB_QUERY_AGENTIC]
|
|
1607
|
+
want = len(declared_agentic)
|
|
1608
|
+
if len(granted) >= want:
|
|
1609
|
+
typer.secho(f" ✓ rag {_KB_QUERY_AGENTIC} on {len(granted)} KB(s)", fg=typer.colors.GREEN)
|
|
1610
|
+
return 0
|
|
1611
|
+
if not granted:
|
|
1612
|
+
typer.secho(
|
|
1613
|
+
f" ✗ rag declares {want} agentic KB(s) but holds no {_KB_QUERY_AGENTIC} grant — "
|
|
1614
|
+
"agentic_search/agentic_answer will 403. Check every entry in "
|
|
1615
|
+
"agentic_knowledge_bases also appears in knowledge_bases, spelled identically, "
|
|
1616
|
+
"then re-publish.",
|
|
1617
|
+
fg=typer.colors.RED,
|
|
1618
|
+
)
|
|
1619
|
+
return 1
|
|
1620
|
+
typer.secho(
|
|
1621
|
+
f" ✗ rag declares {want} agentic KB(s) but only {len(granted)} grant(s) landed — "
|
|
1622
|
+
"an entry probably names a KB that knowledge_bases does not, or that does not exist in "
|
|
1623
|
+
"this workspace.",
|
|
1624
|
+
fg=typer.colors.RED,
|
|
1625
|
+
)
|
|
1626
|
+
return 1
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _doctor_check_grants(entries: list[dict], declared: set[str]) -> int:
|
|
1630
|
+
held = {str(r.get("permission", "")) for r in entries}
|
|
1631
|
+
problems = 0
|
|
1632
|
+
for svc in sorted(declared):
|
|
1633
|
+
prefix = _GRANT_NAMESPACE.get(svc)
|
|
1634
|
+
if prefix is None:
|
|
1635
|
+
# rag is the deliberate case: its access IS the per-KB grant, not a service-level one.
|
|
1636
|
+
continue
|
|
1637
|
+
matching = sorted(p for p in held if p.startswith(prefix))
|
|
1638
|
+
if matching:
|
|
1639
|
+
typer.secho(f" ✓ {svc:9} {', '.join(matching)}", fg=typer.colors.GREEN)
|
|
1640
|
+
else:
|
|
1641
|
+
problems += 1
|
|
1642
|
+
typer.secho(
|
|
1643
|
+
f" ✗ {svc:9} no {prefix}* grant — every call 403s. Re-publish so agent-hub mints it.",
|
|
1644
|
+
fg=typer.colors.RED,
|
|
1645
|
+
)
|
|
1646
|
+
return problems
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def _doctor_check_memory(client: httpx.Client, base: str, headers: dict, agent_id: str, workspace: str) -> int:
|
|
1650
|
+
"""The row memory keeps in its OWN table. A grant does not create it."""
|
|
1651
|
+
resp = client.get(f"{base}/api/memory/v1/agents/{agent_id}/stats", headers=headers)
|
|
1652
|
+
if resp.status_code == 200:
|
|
1653
|
+
typer.secho(" ✓ memory registered in memory's tenant table", fg=typer.colors.GREEN)
|
|
1654
|
+
return 0
|
|
1655
|
+
if resp.status_code == 404:
|
|
1656
|
+
typer.secho(
|
|
1657
|
+
" ✗ memory NOT registered (404 MEM_AGENT_NOT_FOUND) — the grant is not enough. Fix:\n"
|
|
1658
|
+
f" POST {base}/api/memory/v1/workspaces/{workspace}/agents "
|
|
1659
|
+
f'{{"agent_id": "{agent_id}"}}\n'
|
|
1660
|
+
" or set AGENT_HUB_MEMORY_AUTO_PROVISION_ENABLED on agent-hub and re-publish.",
|
|
1661
|
+
fg=typer.colors.RED,
|
|
1662
|
+
)
|
|
1663
|
+
return 1
|
|
1664
|
+
typer.secho(f" ? memory unexpected status {resp.status_code} from stats", fg=typer.colors.YELLOW)
|
|
1665
|
+
return 0
|
|
1666
|
+
|
|
1667
|
+
|
|
1389
1668
|
@agent_app.command()
|
|
1390
1669
|
def status(
|
|
1391
1670
|
agent: str = typer.Argument(..., help="Agent name, e.g. docs-qa."),
|
|
@@ -1534,7 +1813,10 @@ def retire(
|
|
|
1534
1813
|
workspace_id: str | None = typer.Option(
|
|
1535
1814
|
None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
|
|
1536
1815
|
),
|
|
1537
|
-
|
|
1816
|
+
# FDP-4717 (3): `--yes`/`-y` accepted as aliases — see `threads delete`.
|
|
1817
|
+
force: bool = typer.Option(
|
|
1818
|
+
False, "--force", "--yes", "-y", help="Skip the open-approvals guard and the confirmation prompt."
|
|
1819
|
+
),
|
|
1538
1820
|
) -> None:
|
|
1539
1821
|
"""Retire ONE version — terminal (FDP-3439 / US-24). A Live version is unpublished first
|
|
1540
1822
|
(that traffic stops). The operator tears the version's pods/routes down on the retire
|
|
@@ -1582,13 +1864,25 @@ def retire(
|
|
|
1582
1864
|
@agent_app.command()
|
|
1583
1865
|
def delete(
|
|
1584
1866
|
agent: str = typer.Argument(..., help="Agent name to delete ENTIRELY, e.g. rag-qa."),
|
|
1867
|
+
purge_knowledge_bases: bool = typer.Option(
|
|
1868
|
+
False,
|
|
1869
|
+
"--purge-knowledge-bases",
|
|
1870
|
+
help=(
|
|
1871
|
+
"Also delete the knowledge bases this agent created. OFF by default: an agent's KBs "
|
|
1872
|
+
"normally outlive it as ordinary workspace resources, still reachable by anyone with "
|
|
1873
|
+
"the workspace role."
|
|
1874
|
+
),
|
|
1875
|
+
),
|
|
1585
1876
|
api_base: str | None = typer.Option(
|
|
1586
1877
|
None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong)."
|
|
1587
1878
|
),
|
|
1588
1879
|
workspace_id: str | None = typer.Option(
|
|
1589
1880
|
None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
|
|
1590
1881
|
),
|
|
1591
|
-
|
|
1882
|
+
# FDP-4717 (3): `--yes`/`-y` accepted as aliases — see `threads delete`.
|
|
1883
|
+
force: bool = typer.Option(
|
|
1884
|
+
False, "--force", "--yes", "-y", help="Skip the open-approvals guard and the confirmation prompt."
|
|
1885
|
+
),
|
|
1592
1886
|
) -> None:
|
|
1593
1887
|
"""Delete the WHOLE agent (FDP-3439 / US-24): unpublish Live, retire every staged/live
|
|
1594
1888
|
version, then soft-delete the registry entry. All traffic stops; pods/routes tear down
|
|
@@ -1629,7 +1923,14 @@ def delete(
|
|
|
1629
1923
|
if skipped:
|
|
1630
1924
|
tags = ", ".join(str(v.get("version")) for v in skipped)
|
|
1631
1925
|
typer.echo(f" skipped non-retirable version(s): {tags} (soft-deleted with the agent)")
|
|
1632
|
-
r = client.delete(
|
|
1926
|
+
r = client.delete(
|
|
1927
|
+
f"{base}/api/agent-hub/v1/agents/{agent_id}",
|
|
1928
|
+
headers=headers,
|
|
1929
|
+
# Sent only when asked. agent-hub defaults it to false, so omitting it would work
|
|
1930
|
+
# too — spelling it out keeps the request readable in a proxy log, where "was the
|
|
1931
|
+
# corpus deleted?" is a question someone will eventually have to answer.
|
|
1932
|
+
params={"purge_knowledge_bases": str(purge_knowledge_bases).lower()},
|
|
1933
|
+
)
|
|
1633
1934
|
if r.status_code in (200, 204, 404): # 404 = already gone (idempotent re-run)
|
|
1634
1935
|
typer.secho(
|
|
1635
1936
|
f"✓ deleted {agent} (soft) — pods/routes tear down via the operator; "
|