adopt-cli 0.3.0__tar.gz → 0.4.0__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.
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/PKG-INFO +7 -1
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/pyproject.toml +21 -1
- adopt_cli-0.4.0/src/adopt_cli/_build_info.py +6 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_ask_support.py +311 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_ci_sense_probes.py +229 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_ci_sense_support.py +230 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_draft_support.py +379 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_drafting.py +127 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_handover_support.py +583 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_knowledge_support.py +726 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_map_support.py +207 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_pack_support.py +317 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_probe_support.py +809 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_refresh_support.py +633 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_remote_support.py +57 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/_review_remote.py +86 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/answer.py +318 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/ask.py +127 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/boundary.py +1 -1
- adopt_cli-0.4.0/src/adopt_cli/commands/ci_sense.py +226 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/coverage.py +9 -1
- adopt_cli-0.4.0/src/adopt_cli/commands/draft.py +81 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/handover.py +809 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/init.py +2 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/knowledge.py +1191 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/map_command.py +230 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/pack.py +225 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/probe.py +243 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/pull.py +287 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/refresh.py +316 -0
- adopt_cli-0.4.0/src/adopt_cli/commands/serve.py +101 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/config.py +81 -4
- adopt_cli-0.4.0/src/adopt_cli/main.py +279 -0
- adopt_cli-0.4.0/src/adopt_cli/remote.py +307 -0
- adopt_cli-0.4.0/src/adopt_cli/replica.py +240 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/store_option.py +99 -5
- adopt_cli-0.3.0/src/adopt_cli/_build_info.py +0 -6
- adopt_cli-0.3.0/src/adopt_cli/main.py +0 -150
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/.gitignore +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/LICENSE +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/NOTICE +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/__init__.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/__init__.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/agent.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/detect.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/doctor.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/freshness.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/identity.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/interchange.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/policy.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/store.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/version.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/json_out.py +0 -0
- {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: adopt-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: The adopt command surface: config resolution, JSON mode, exit codes.
|
|
5
5
|
Project-URL: Homepage, https://github.com/onboardux/onboard-core
|
|
6
6
|
Project-URL: Source, https://github.com/onboardux/onboard-core
|
|
@@ -9,14 +9,20 @@ Author: The Adopt Authors
|
|
|
9
9
|
License-Expression: Apache-2.0
|
|
10
10
|
Requires-Python: >=3.12
|
|
11
11
|
Requires-Dist: adopt-agent
|
|
12
|
+
Requires-Dist: adopt-ask
|
|
12
13
|
Requires-Dist: adopt-const
|
|
13
14
|
Requires-Dist: adopt-coverage
|
|
14
15
|
Requires-Dist: adopt-detect
|
|
15
16
|
Requires-Dist: adopt-export
|
|
16
17
|
Requires-Dist: adopt-freshness
|
|
18
|
+
Requires-Dist: adopt-handover
|
|
17
19
|
Requires-Dist: adopt-identity
|
|
20
|
+
Requires-Dist: adopt-knowledge
|
|
21
|
+
Requires-Dist: adopt-map
|
|
22
|
+
Requires-Dist: adopt-model
|
|
18
23
|
Requires-Dist: adopt-obs
|
|
19
24
|
Requires-Dist: adopt-policy
|
|
25
|
+
Requires-Dist: adopt-probe
|
|
20
26
|
Requires-Dist: adopt-scope
|
|
21
27
|
Requires-Dist: adopt-store
|
|
22
28
|
Requires-Dist: click<9,>=8
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "adopt-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "The adopt command surface: config resolution, JSON mode, exit codes."
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -12,14 +12,34 @@ authors = [{ name = "The Adopt Authors" }]
|
|
|
12
12
|
# `adopt_cli` (CR-36) -- sees nothing new.
|
|
13
13
|
dependencies = [
|
|
14
14
|
"adopt-agent",
|
|
15
|
+
# Build 3. `commands/ask.py` imports it inside the command body, but the
|
|
16
|
+
# `first-party-deps` gate is about what the distribution needs installed, not
|
|
17
|
+
# about when the import runs: `pip install adopt-cli` must always yield a
|
|
18
|
+
# working `adopt ask`.
|
|
19
|
+
"adopt-ask",
|
|
15
20
|
"adopt-const",
|
|
16
21
|
"adopt-coverage",
|
|
17
22
|
"adopt-detect",
|
|
18
23
|
"adopt-export",
|
|
19
24
|
"adopt-freshness",
|
|
25
|
+
# Build 4. `commands/pack.py` imports it inside the command body; the
|
|
26
|
+
# `first-party-deps` gate is about what the distribution needs installed,
|
|
27
|
+
# so `pip install adopt-cli` must always yield a working `adopt pack`.
|
|
28
|
+
"adopt-handover",
|
|
20
29
|
"adopt-identity",
|
|
30
|
+
"adopt-knowledge",
|
|
31
|
+
"adopt-map",
|
|
32
|
+
# `adopt_model._enums` is imported directly by `commands/init.py` and
|
|
33
|
+
# `commands/boundary.py`. It arrived transitively through `adopt-store` until
|
|
34
|
+
# someone installed a subset that did not include it, which is why an
|
|
35
|
+
# undeclared dependency is a defect even while it happens to resolve.
|
|
36
|
+
"adopt-model",
|
|
21
37
|
"adopt-obs",
|
|
22
38
|
"adopt-policy",
|
|
39
|
+
# Build 5. `commands/probe.py` imports it inside the command body; the
|
|
40
|
+
# `first-party-deps` gate is about what the distribution needs installed,
|
|
41
|
+
# so `pip install adopt-cli` must always yield a working `adopt probe run`.
|
|
42
|
+
"adopt-probe",
|
|
23
43
|
"adopt-scope",
|
|
24
44
|
"adopt-store",
|
|
25
45
|
"typer>=0.12,<1",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Generated by scripts/embed_build_info.py; do not edit in a release build."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
SBOM_SHA256: Final[str | None] = '86636f02cad8578ffdc6c4b39fe311c0a8191cafd5fcb323ddb732447dc6b0c8'
|
|
6
|
+
BUILD_ID: Final[str | None] = 'github:onboardux/onboard-core:34144382673:1:a1b32208f0a296bc708e283d860023ce7fdf4609'
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""The ask pipeline, in one place, so `adopt ask` and `adopt serve` cannot diverge.
|
|
2
|
+
|
|
3
|
+
v6.1 says `adopt serve` "exposes the same paths on loopback". *Same* is doing
|
|
4
|
+
real work in that sentence: two implementations of the ask pipeline would be two
|
|
5
|
+
places the freshness check could be skipped, two boundary guards to keep in step
|
|
6
|
+
and two answers to one question depending on which door you came through. So the
|
|
7
|
+
composition root moved here when serve arrived, and both verbs call
|
|
8
|
+
`answer_question`. The payload-parity test asserts what this arrangement makes
|
|
9
|
+
structurally true.
|
|
10
|
+
|
|
11
|
+
**The order below is the contract**, not an implementation detail:
|
|
12
|
+
|
|
13
|
+
refresh the derived index -> retrieve candidates -> resolve freshness for
|
|
14
|
+
every one -> compose -> guard against the boundary -> log/escalate -> render
|
|
15
|
+
|
|
16
|
+
`compose` accepts only candidates already paired with a resolution, so the third
|
|
17
|
+
step cannot be skipped by editing this file: the worst a future edit can do is
|
|
18
|
+
fail to type-check. That is critical semantic invariant #5, and
|
|
19
|
+
`adopt_ask.branch` records why it is a type rather than a check.
|
|
20
|
+
|
|
21
|
+
**The store is opened by the caller**, because the two callers want different
|
|
22
|
+
lifetimes -- one command, versus one request on a server that must not hold a
|
|
23
|
+
connection across threads.
|
|
24
|
+
|
|
25
|
+
**Build 7 gives escalation a second destination.** With a control plane
|
|
26
|
+
configured, a consented escalation is posted to
|
|
27
|
+
`POST /v1/systems/{id}/escalations` and opened in the tenant's canon, where the
|
|
28
|
+
owner is resolved and the draft is routed; with none, it is written to the local
|
|
29
|
+
store exactly as Build 3 wrote it. **The consent decision is unchanged and
|
|
30
|
+
happens first** -- `consented()` still decides whether the text may be stored at
|
|
31
|
+
all, and only then does the destination question arise. Reversing those two
|
|
32
|
+
would make a network configuration able to change what a human agreed to.
|
|
33
|
+
|
|
34
|
+
**Answering is never routed.** Only the escalation is: reading is what a replica
|
|
35
|
+
is for, so `adopt ask` on an operated system answers from the local store at
|
|
36
|
+
local latency and reaches the plane only when a question turns into work
|
|
37
|
+
somebody else has to do.
|
|
38
|
+
|
|
39
|
+
**Synthesis is the last step and changes nothing but the rendering.** It runs
|
|
40
|
+
only when an adapter is configured, only over passages the branch already
|
|
41
|
+
approved, and only if `ground` accepts what came back (invariant #7). The
|
|
42
|
+
`--json` payload is deliberately identical either way except for one added
|
|
43
|
+
`synthesis` block: a caller that scripts on `branch` and `citations` must not
|
|
44
|
+
see a different shape because somebody set `ADOPT_ADAPTER` -- that would make
|
|
45
|
+
the no-model mode a different product rather than a complete one (R3).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from collections.abc import Callable, Mapping
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from adopt_cli.store_option import configured_question_log, configured_search
|
|
53
|
+
from adopt_obs import Clock, SystemClock
|
|
54
|
+
|
|
55
|
+
__all__ = ["AskOutcome", "answer_question"]
|
|
56
|
+
|
|
57
|
+
#: The config key passive logging reads. Named once so the registry entry and
|
|
58
|
+
#: the read have exactly one spelling between them.
|
|
59
|
+
LOG_QUESTIONS_KEY = "ADOPT_ASK_LOG_QUESTIONS"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class AskOutcome:
|
|
64
|
+
"""One answered question: the machine payload, the human text, the branch."""
|
|
65
|
+
|
|
66
|
+
payload: dict[str, Any]
|
|
67
|
+
human: str
|
|
68
|
+
branch: str
|
|
69
|
+
escalation_id: str | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def answer_question(
|
|
73
|
+
handle: Any,
|
|
74
|
+
question: str,
|
|
75
|
+
*,
|
|
76
|
+
scope: str | None = None,
|
|
77
|
+
reindex: bool = False,
|
|
78
|
+
escalate_flag: bool = False,
|
|
79
|
+
interactive: bool = False,
|
|
80
|
+
confirm: Callable[[str], bool] | None = None,
|
|
81
|
+
resolved_config: Mapping[str, str | None] | None = None,
|
|
82
|
+
synthesize: bool = True,
|
|
83
|
+
) -> AskOutcome:
|
|
84
|
+
"""Answer `question` against the open store `handle`.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
handle: An open, writable store handle. Writable because answering may
|
|
88
|
+
rebuild the retrieval index in the annex beside it -- answering
|
|
89
|
+
itself writes no canon, and `escalate_flag` is the only thing here
|
|
90
|
+
that does.
|
|
91
|
+
question: The question, verbatim.
|
|
92
|
+
scope: `firm/engagement/system/environment`, or `None` for the store's.
|
|
93
|
+
reindex: Rebuild the index even if its stamp says it is current.
|
|
94
|
+
escalate_flag: The `--escalate` flag, or the request's `escalate` field.
|
|
95
|
+
**The action is the consent** (F2), so this alone records the text.
|
|
96
|
+
interactive: Whether a human can answer a prompt. `False` for `--json`
|
|
97
|
+
and for **every** serve request, because an HTTP client is not a
|
|
98
|
+
person and a server that prompted would hang holding a socket.
|
|
99
|
+
confirm: How to ask, injected. `None` in every non-interactive context.
|
|
100
|
+
resolved_config: Config key -> value, for the passive-logging switch.
|
|
101
|
+
Injected so a test can drive the key without the environment.
|
|
102
|
+
synthesize: Whether to attempt the optional synthesis pass. `True` means
|
|
103
|
+
*try*, not *require*: with no adapter configured it is a no-op and
|
|
104
|
+
the extractive answer serves, which is the complete no-model mode
|
|
105
|
+
(R3). Passed `False` by tests that assert the extractive path.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The `--json` payload, the human rendering, and the branch.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
AdoptError: ``ASK_OUTSIDE_BOUNDARY`` when the declared boundary refuses
|
|
112
|
+
the answer, or none is declared; ``ESCALATION_NOT_FOUND`` when a
|
|
113
|
+
consented escalation has no system to attach to.
|
|
114
|
+
"""
|
|
115
|
+
from adopt_ask import compose, guard, json_payload, render, retrieve
|
|
116
|
+
from adopt_ask.branch import Resolved
|
|
117
|
+
from adopt_ask.escalate import consented, escalate, may_escalate
|
|
118
|
+
from adopt_ask.questionlog import log_question, should_log
|
|
119
|
+
from adopt_ask.synthesis import SYNTHESIS_PROMPT_REF, render_with
|
|
120
|
+
|
|
121
|
+
from adopt_cli.commands._map_support import resolve_scope
|
|
122
|
+
from adopt_cli.config import resolve_all
|
|
123
|
+
from adopt_detect import BoundaryView
|
|
124
|
+
from adopt_freshness import resolve_freshness
|
|
125
|
+
from adopt_obs import AdoptError, ErrorCode
|
|
126
|
+
|
|
127
|
+
resolved_scope = resolve_scope(handle, scope)
|
|
128
|
+
clock: Clock = handle.clock if handle.clock is not None else SystemClock()
|
|
129
|
+
|
|
130
|
+
with configured_search(handle, clock=clock) as search:
|
|
131
|
+
search.refresh(force=reindex)
|
|
132
|
+
candidates = retrieve(search, question)
|
|
133
|
+
verified = search.verified_in_store(
|
|
134
|
+
[candidate.passage.revision_id for candidate in candidates]
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
freshness_records = handle.freshness_records()
|
|
138
|
+
resolved = tuple(
|
|
139
|
+
Resolved(
|
|
140
|
+
candidate=candidate,
|
|
141
|
+
freshness=resolve_freshness(freshness_records, candidate.passage.item_id, clock=clock),
|
|
142
|
+
)
|
|
143
|
+
for candidate in candidates
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
answer = compose(resolved, verified, question)
|
|
147
|
+
|
|
148
|
+
system = resolved_scope.system
|
|
149
|
+
environment = resolved_scope.environment
|
|
150
|
+
row = (
|
|
151
|
+
None
|
|
152
|
+
if system is None
|
|
153
|
+
else handle.boundary().current(
|
|
154
|
+
system_id=system.id,
|
|
155
|
+
environment_id=None if environment is None else environment.id,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
guard(
|
|
159
|
+
answer,
|
|
160
|
+
None if row is None else BoundaryView.of(row, archetype=None),
|
|
161
|
+
scope=resolved_scope,
|
|
162
|
+
occurred_at=clock.now(),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Passive logging is asked **before** escalation and is entirely separate
|
|
166
|
+
# from it: `--escalate` is consent to store one question, and the config key
|
|
167
|
+
# is an operator's standing decision to store all of them. Reading either
|
|
168
|
+
# from the other would collapse F2's two halves into one.
|
|
169
|
+
config = (
|
|
170
|
+
resolved_config
|
|
171
|
+
if resolved_config is not None
|
|
172
|
+
else {item.key: item.value for item in resolve_all()}
|
|
173
|
+
)
|
|
174
|
+
if should_log(config, key=LOG_QUESTIONS_KEY):
|
|
175
|
+
with configured_question_log(handle) as question_log:
|
|
176
|
+
log_question(
|
|
177
|
+
question_log, answer, scope_ref=resolved_scope.path(), asked_at=clock.now()
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
escalation_id: str | None = None
|
|
181
|
+
routed_to_plane = False
|
|
182
|
+
if consented(answer, escalate_flag=escalate_flag, interactive=interactive, confirm=confirm):
|
|
183
|
+
if system is None:
|
|
184
|
+
raise AdoptError(
|
|
185
|
+
ErrorCode.ESCALATION_NOT_FOUND,
|
|
186
|
+
message=f"the scope {resolved_scope.path()!r} names no system, so there is "
|
|
187
|
+
"nothing to open a question against",
|
|
188
|
+
hint="Pass --scope firm/engagement/system/environment, or open a store whose "
|
|
189
|
+
"default scope resolves to a system.",
|
|
190
|
+
)
|
|
191
|
+
from adopt_cli.commands._remote_support import configured_remote
|
|
192
|
+
|
|
193
|
+
remote = configured_remote(config)
|
|
194
|
+
if remote is None:
|
|
195
|
+
# **The one capture this module performs, and the one write here that
|
|
196
|
+
# is canon.** `ask` and `serve` open the store writable with the
|
|
197
|
+
# guard opted out, because answering rebuilds the retrieval index in
|
|
198
|
+
# the annex and a replica exists to be asked questions. An
|
|
199
|
+
# escalation is different: it lands an `escalation` row that the
|
|
200
|
+
# next `adopt pull` replaces wholesale, so the question a human
|
|
201
|
+
# recorded would be gone with no trace. The check is here rather
|
|
202
|
+
# than at the open because which branch is taken is decided here --
|
|
203
|
+
# a configured remote routes to the plane and writes nothing local.
|
|
204
|
+
from adopt_cli.replica import refuse_write_to_replica
|
|
205
|
+
|
|
206
|
+
refuse_write_to_replica(handle.backend.path, verb="ask --escalate")
|
|
207
|
+
escalation_id = escalate(handle.governance(), answer, system_id=system.id)
|
|
208
|
+
else:
|
|
209
|
+
escalation_id = _escalate_remotely(remote, question)
|
|
210
|
+
routed_to_plane = True
|
|
211
|
+
|
|
212
|
+
payload = dict(json_payload(answer))
|
|
213
|
+
human = render(answer)
|
|
214
|
+
|
|
215
|
+
synthesis = _synthesize(answer, question=question, config=config) if synthesize else None
|
|
216
|
+
if synthesis is not None:
|
|
217
|
+
payload["synthesis"] = {
|
|
218
|
+
"answer_md": synthesis.answer_md,
|
|
219
|
+
"cited_revision_ids": list(synthesis.cited_revision_ids),
|
|
220
|
+
"prompt_ref": SYNTHESIS_PROMPT_REF,
|
|
221
|
+
}
|
|
222
|
+
human = render_with(answer, synthesis)
|
|
223
|
+
if escalation_id is not None:
|
|
224
|
+
payload["escalation_id"] = escalation_id
|
|
225
|
+
payload["routed_to_plane"] = routed_to_plane
|
|
226
|
+
where = "on the control plane" if routed_to_plane else "in this store"
|
|
227
|
+
human += f"\n\nRecorded as open question {escalation_id} {where}."
|
|
228
|
+
human += f'\nAnswer it with: adopt answer {escalation_id} --text "..."'
|
|
229
|
+
if routed_to_plane:
|
|
230
|
+
human += "\nIts owner has been routed a draft reply."
|
|
231
|
+
elif may_escalate(answer) and not escalate_flag:
|
|
232
|
+
human += "\n\nRecord it as an open question with `--escalate`."
|
|
233
|
+
|
|
234
|
+
return AskOutcome(
|
|
235
|
+
payload=payload, human=human, branch=answer.branch, escalation_id=escalation_id
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _synthesize(answer: Any, *, question: str, config: Mapping[str, str | None]) -> Any:
|
|
240
|
+
"""One optional model call. `None` for every reason not to have made it.
|
|
241
|
+
|
|
242
|
+
**Every failure here is silent and returns `None`**, because the answer is
|
|
243
|
+
already complete without it: no adapter configured, offline refused, budget
|
|
244
|
+
exhausted, provider error, ungrounded output. Turning any of those into a
|
|
245
|
+
visible failure would make an optional improvement a required dependency,
|
|
246
|
+
which is exactly what R3's no-model mode forbids.
|
|
247
|
+
|
|
248
|
+
The idempotency key is the question plus the cited set, so re-asking an
|
|
249
|
+
unchanged question over unchanged knowledge replays the recorded run instead
|
|
250
|
+
of paying for it again (PRD F13.5) -- and a store whose knowledge has moved
|
|
251
|
+
on asks a genuinely new question.
|
|
252
|
+
"""
|
|
253
|
+
import hashlib
|
|
254
|
+
|
|
255
|
+
from adopt_ask.synthesis import synthesize as run_synthesis
|
|
256
|
+
|
|
257
|
+
from adopt_agent import Runner
|
|
258
|
+
from adopt_cli.commands.agent import adapter_settings, prompts_root
|
|
259
|
+
from adopt_cli.store_option import configured_annex
|
|
260
|
+
from adopt_obs import AdoptError
|
|
261
|
+
|
|
262
|
+
if not answer.citations:
|
|
263
|
+
return None
|
|
264
|
+
offline, adapter_id, model, endpoint = adapter_settings()
|
|
265
|
+
if not adapter_id:
|
|
266
|
+
# No adapter is the ordinary case and is not a failure: the extractive
|
|
267
|
+
# answer is the product (`04` §3), not a fallback from one.
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
material = "\0".join([question, *(c.revision_id for c in answer.citations)])
|
|
271
|
+
key = f"ask-001:{hashlib.sha256(material.encode('utf-8')).hexdigest()}"
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
with configured_annex() as annex:
|
|
275
|
+
runner = Runner(
|
|
276
|
+
annex=annex,
|
|
277
|
+
scope_ref=question,
|
|
278
|
+
skills_root=prompts_root(),
|
|
279
|
+
offline=offline,
|
|
280
|
+
adapter_id=adapter_id,
|
|
281
|
+
model=model,
|
|
282
|
+
endpoint=endpoint,
|
|
283
|
+
)
|
|
284
|
+
return run_synthesis(runner, answer, idempotency_key=key)
|
|
285
|
+
except AdoptError:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _escalate_remotely(remote: Any, question: str) -> str | None:
|
|
290
|
+
"""Open the question on the configured plane. Returns its id.
|
|
291
|
+
|
|
292
|
+
**The plane runs the same `adopt_ask` code on its own side**, so the branch
|
|
293
|
+
it decides and the branch decided here agree -- and the id that comes back is
|
|
294
|
+
the plane's, which is the one `adopt answer` will confirm against. Returning
|
|
295
|
+
a local id would be worse than returning none: it would name a row nobody
|
|
296
|
+
else can see, in a command whose whole point is that somebody else acts on it.
|
|
297
|
+
|
|
298
|
+
`None` comes back only when the plane answered `escalated: false`, meaning
|
|
299
|
+
*it* could answer the question. That is a real outcome rather than a failure:
|
|
300
|
+
the replica is behind the plane's canon, which is exactly what `adopt pull`
|
|
301
|
+
is for.
|
|
302
|
+
"""
|
|
303
|
+
from adopt_cli.remote import post_json
|
|
304
|
+
|
|
305
|
+
response = post_json(
|
|
306
|
+
remote,
|
|
307
|
+
f"/v1/systems/{remote.system_id}/escalations",
|
|
308
|
+
{"question": question},
|
|
309
|
+
)
|
|
310
|
+
escalation_id = response.get("escalation_id")
|
|
311
|
+
return str(escalation_id) if isinstance(escalation_id, str) else None
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""The probe half of `adopt ci-sense`: run them here, decide nothing.
|
|
2
|
+
|
|
3
|
+
**Sensing executes where access exists** (v6.1 §6 Build 8 F4). A probe reaches
|
|
4
|
+
a client's own deployment, so the CI is where it can be run at all for every
|
|
5
|
+
system behind a network the plane cannot enter. This module runs the probes the
|
|
6
|
+
local replica declares, through `adopt_probe.runner` -- the one module in the
|
|
7
|
+
programme permitted to open a probe socket -- and renders what they observed
|
|
8
|
+
into the sense payload.
|
|
9
|
+
|
|
10
|
+
**Nothing is stored and nothing is compared.** `execute_probe` is called with
|
|
11
|
+
`records=None` and with a sink that carries the heartbeat instead of writing it,
|
|
12
|
+
so no `probe_run`, no `probe_observation` and no `sensor_heartbeat` lands in the
|
|
13
|
+
replica: `adopt ci-sense` writes nothing locally, because after activation the
|
|
14
|
+
plane is the sole writer of an operated system's canon (R9). The comparison is
|
|
15
|
+
the plane's too, against **its** baseline, for `_ci_sense_support`'s reason -- a
|
|
16
|
+
replica is only as fresh as its last `adopt pull`, and comparing against a stale
|
|
17
|
+
baseline would report drift that a current baseline explains.
|
|
18
|
+
|
|
19
|
+
**What travels is what a comparison needs and nothing more.** The step outputs
|
|
20
|
+
travel because Build 5's `compare_run` is a text comparison and cannot be done
|
|
21
|
+
on digests; they are the client's *own system's* responses, already canon in
|
|
22
|
+
their store, and already redacted of every declared secret by the runner before
|
|
23
|
+
this module can see them. No line of the repository travels here any more than
|
|
24
|
+
it does in the artifact half.
|
|
25
|
+
|
|
26
|
+
**The revision id travels with every result, and it is load-bearing.** A run of
|
|
27
|
+
revision B compared against a baseline of revision A is the *question* changing,
|
|
28
|
+
not the system -- so the plane reports `probe_changed` and produces no change
|
|
29
|
+
event at all. Without the revision id on the wire that distinction is
|
|
30
|
+
unreachable, and every probe edit would arrive as a client incident.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from collections.abc import Sequence
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
__all__ = ["ProbeSection", "run_payload_probes"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class ProbeSection:
|
|
42
|
+
"""What the probe half of one ci-sense run observed, ready to be rendered.
|
|
43
|
+
|
|
44
|
+
**Every field that is not a result is a statement about what was not looked
|
|
45
|
+
at**, which is `ProbeDelta`'s rule and its reason: a run that examined
|
|
46
|
+
nothing must not read as a clean one. `reason` says why the half did not run
|
|
47
|
+
at all; `skipped` says which probes had no revision to execute.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
ran: bool = False
|
|
51
|
+
reason: str | None = None
|
|
52
|
+
results: list[dict[str, Any]] = field(default_factory=list)
|
|
53
|
+
skipped: list[dict[str, str]] = field(default_factory=list)
|
|
54
|
+
heartbeats: list[dict[str, Any]] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
def payload(self) -> dict[str, Any]:
|
|
57
|
+
"""The `probes` object as the payload carries it.
|
|
58
|
+
|
|
59
|
+
Sorted by probe name for `build_payload`'s reason: the framing fixture
|
|
60
|
+
is compared byte for byte across two repositories, and a section that
|
|
61
|
+
reordered per run could not be pinned.
|
|
62
|
+
"""
|
|
63
|
+
return {
|
|
64
|
+
"ran": self.ran,
|
|
65
|
+
"reason": self.reason,
|
|
66
|
+
"results": sorted(self.results, key=lambda row: str(row["probe"])),
|
|
67
|
+
"skipped": sorted(self.skipped, key=lambda row: row["probe"]),
|
|
68
|
+
"heartbeats": sorted(self.heartbeats, key=lambda row: str(row["probe"])),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def drifted_candidates(self) -> int:
|
|
73
|
+
"""How many probes completed and could therefore be compared.
|
|
74
|
+
|
|
75
|
+
Named *candidates* rather than *drifted* deliberately: this side does not
|
|
76
|
+
compare anything, so it cannot know what drifted. The count exists for
|
|
77
|
+
the CI log line, where "ran 3, 3 comparable" and "ran 3, 0 comparable"
|
|
78
|
+
are very different mornings.
|
|
79
|
+
"""
|
|
80
|
+
return sum(1 for row in self.results if row["outcome"] in ("success", "diff"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_payload_probes(handle: Any, scope: Any, *, allow_network: bool = False) -> ProbeSection:
|
|
84
|
+
"""Execute the probes this scope declares and render what they observed.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
handle: The read-only store handle ci-sense already opened. Used for
|
|
88
|
+
**reads only** -- the definitions and their active revisions.
|
|
89
|
+
scope: The resolved four-level scope.
|
|
90
|
+
allow_network: Permits the model adapter for a probe's `prompt` steps.
|
|
91
|
+
An `http` step needs no such flag; the host allow-list is the
|
|
92
|
+
invariant there and it is enforced inside the runner.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
A `ProbeSection`. `ran=False` with a reason when there is nothing to run,
|
|
96
|
+
which is not the same answer as "ran and found nothing".
|
|
97
|
+
"""
|
|
98
|
+
import os
|
|
99
|
+
from contextlib import ExitStack
|
|
100
|
+
|
|
101
|
+
from adopt_probe import execute_probe, parse_probe
|
|
102
|
+
|
|
103
|
+
from adopt_cli.commands._probe_support import (
|
|
104
|
+
active_revision,
|
|
105
|
+
probes_in_scope,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
definitions = probes_in_scope(handle, scope)
|
|
109
|
+
if not definitions:
|
|
110
|
+
return ProbeSection(ran=False, reason="no probes are defined in this scope")
|
|
111
|
+
|
|
112
|
+
section = ProbeSection(ran=True)
|
|
113
|
+
pairs: list[tuple[Any, Any, Any]] = []
|
|
114
|
+
for probe in definitions:
|
|
115
|
+
revision = active_revision(handle, probe)
|
|
116
|
+
if revision is None:
|
|
117
|
+
section.skipped.append({"probe": probe.name, "reason": "no revision"})
|
|
118
|
+
continue
|
|
119
|
+
if revision.status != "active":
|
|
120
|
+
section.skipped.append(
|
|
121
|
+
{"probe": probe.name, "reason": f"revision is {revision.status}"}
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
pairs.append((probe, revision, parse_probe(revision.capability_manifest)))
|
|
125
|
+
|
|
126
|
+
if not pairs:
|
|
127
|
+
section.ran = False
|
|
128
|
+
section.reason = "no probe in this scope has an active revision"
|
|
129
|
+
return section
|
|
130
|
+
|
|
131
|
+
# The annex stays open for the whole set and closes with the stack, exactly
|
|
132
|
+
# as `execute_in_scope` holds it -- a `prompt` step's model call is metered
|
|
133
|
+
# through the one seam, and opening an annex per probe would make each run's
|
|
134
|
+
# budget its own.
|
|
135
|
+
with ExitStack() as stack:
|
|
136
|
+
agent = _agent_for([spec for _, _, spec in pairs], stack, allow_network=allow_network)
|
|
137
|
+
for probe, revision, spec in pairs:
|
|
138
|
+
sink = _CarriedHeartbeat()
|
|
139
|
+
report = execute_probe(
|
|
140
|
+
spec,
|
|
141
|
+
probe_definition_id=probe.id,
|
|
142
|
+
probe_definition_revision_id=revision.id,
|
|
143
|
+
# **`records=None`, and that is the verb's whole discipline.** A
|
|
144
|
+
# recorded run here would be a canon write from a caller that is
|
|
145
|
+
# not the writer (R9), so nothing lands in the replica: the run,
|
|
146
|
+
# its observations and its heartbeat all travel instead.
|
|
147
|
+
records=None,
|
|
148
|
+
environ=os.environ,
|
|
149
|
+
agent=agent,
|
|
150
|
+
sensor=sink,
|
|
151
|
+
)
|
|
152
|
+
section.results.append(_result(probe, revision, spec, report))
|
|
153
|
+
section.heartbeats.append(sink.payload(probe.name))
|
|
154
|
+
|
|
155
|
+
return section
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _agent_for(specs: Sequence[Any], stack: Any, *, allow_network: bool) -> Any:
|
|
159
|
+
"""The model seam, opened only when a probe actually has a `prompt` step.
|
|
160
|
+
|
|
161
|
+
`_probe_support._agent_for` unchanged and by import, rather than a second
|
|
162
|
+
construction of the same runner: the adapter resolution, the annex lifetime
|
|
163
|
+
and the offline refusal are all decisions that must be identical to the
|
|
164
|
+
local loop's, and two spellings of them would differ the first time one was
|
|
165
|
+
edited.
|
|
166
|
+
"""
|
|
167
|
+
from adopt_cli.commands._probe_support import _agent_for as build_agent
|
|
168
|
+
|
|
169
|
+
return build_agent(list(specs), stack, allow_network=allow_network, audience="probe")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _result(probe: Any, revision: Any, spec: Any, report: Any) -> dict[str, Any]:
|
|
173
|
+
"""One probe's run, as the payload carries it.
|
|
174
|
+
|
|
175
|
+
`exercises` travels because it is **the only probe -> identity link** (B5's
|
|
176
|
+
`conflict.py` reading, which Build 6's refresh already inherits): the plane
|
|
177
|
+
turns a drift into a change event about the referents the probe declares it
|
|
178
|
+
exercises, and a payload without them would be a finding about nothing.
|
|
179
|
+
|
|
180
|
+
`outputs` and `fingerprints` are parallel to `steps`, in step order, because
|
|
181
|
+
that is the order `compare_run` pairs them against the baseline in. Ordered
|
|
182
|
+
by the loop that ran them rather than sorted -- step 2 compared against step
|
|
183
|
+
0's baseline is a comparison of two unrelated things that would report drift
|
|
184
|
+
with complete confidence.
|
|
185
|
+
"""
|
|
186
|
+
return {
|
|
187
|
+
"probe": probe.name,
|
|
188
|
+
"probe_definition_id": probe.id,
|
|
189
|
+
"probe_definition_revision_id": revision.id,
|
|
190
|
+
"outcome": report.outcome,
|
|
191
|
+
"exercises": list(spec.exercises),
|
|
192
|
+
"outputs": [step.output for step in report.steps],
|
|
193
|
+
"fingerprints": [step.fingerprint for step in report.steps],
|
|
194
|
+
# Already redacted of every declared secret by the runner -- `redact` is
|
|
195
|
+
# applied to a refusal before it reaches the report, because a refusal
|
|
196
|
+
# message is the one place a bad request's interpolated value would
|
|
197
|
+
# otherwise appear.
|
|
198
|
+
"refusal": report.refusal,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _CarriedHeartbeat:
|
|
203
|
+
"""A `SensorSink` that carries the fact instead of writing it.
|
|
204
|
+
|
|
205
|
+
v6.1 §6 B5 requires probe runs to *"emit per-run sensor-health facts locally
|
|
206
|
+
so Builds 6/8 treat probes as sensors without rework"*. In CI there is no
|
|
207
|
+
local sensor worth writing to -- the replica's health is nobody's oracle,
|
|
208
|
+
and a heartbeat there would leave the plane's sensor (the row
|
|
209
|
+
`resolve_freshness` actually reads) hearing nothing while the scope looked
|
|
210
|
+
healthy. So the fact travels.
|
|
211
|
+
|
|
212
|
+
**A sink rather than a mapping of outcome to health**, which was the first
|
|
213
|
+
spelling here. The runner already owns that mapping and applies it before
|
|
214
|
+
calling this; re-deriving it on this side would be a second copy of a rule
|
|
215
|
+
whose two halves would differ the first time either was edited -- and the
|
|
216
|
+
symptom would be a scope whose sensor health disagreed with the run that
|
|
217
|
+
produced it.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(self) -> None:
|
|
221
|
+
self.outcome: str | None = None
|
|
222
|
+
self.detail: str | None = None
|
|
223
|
+
|
|
224
|
+
def heartbeat(self, *, outcome: str, detail: str | None = None) -> None:
|
|
225
|
+
self.outcome = outcome
|
|
226
|
+
self.detail = detail
|
|
227
|
+
|
|
228
|
+
def payload(self, probe_name: str) -> dict[str, Any]:
|
|
229
|
+
return {"probe": probe_name, "outcome": self.outcome, "detail": self.detail}
|