awask 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
awask/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """awask — your agent asks you a question, and acts on your answer.
2
+
3
+ A coding agent that needs a human has exactly one channel today: prose in a
4
+ terminal. That channel loses. The important sentence sits in paragraph four of a
5
+ dense block, the owner is in another window, and the run sits idle for forty
6
+ minutes because nobody knew it was waiting.
7
+
8
+ awask inverts that. The agent writes a small, structured artifact — headline,
9
+ what you need to know, the options, the recommendation, what happens if you say
10
+ nothing — into a durable store. Every surface then renders the SAME card: the
11
+ terminal, a desktop window, a chat DM, a phone.
12
+
13
+ Three properties are load-bearing, and each is a rule rather than a nicety:
14
+
15
+ 1. **The card is durable and out-of-process.** It lives in ``~/.aither/decisions``,
16
+ not in a transcript. A card outlives the run that raised it, so closing the
17
+ laptop does not lose the question, and a process in another terminal can raise
18
+ one without this process cooperating at all.
19
+
20
+ 2. **A card always states its default.** "What happens if you ignore this" is the
21
+ field that makes a card safe to ignore. An agent that cannot name a default is
22
+ not blocked on a decision — it is blocked on doing its own thinking.
23
+
24
+ 3. **Answering closes the loop.** The answer is written back to the raising run's
25
+ steering mailbox, which its hooks drain. A card a human answers into a void is
26
+ worse than no card at all, because it reports a resolution that reached nobody.
27
+
28
+ Zero dependencies — standard library only, on purpose. The store is a directory of
29
+ JSON files, so `pip install awask` is the whole setup: no server, no login, no
30
+ network, and nothing to run before it works.
31
+
32
+ Quickstart
33
+ ----------
34
+
35
+ awask ask "Ship the migration now, or hold for review?" \\
36
+ --summary "Tests pass; the rollback path is untested." \\
37
+ --fact "312 rows affected" \\
38
+ --option "ship|Ship it|goes live in ~2 min, rollback unproven" \\
39
+ --option "hold|Hold|no risk, blocks the release" \\
40
+ --recommend hold --default hold
41
+
42
+ awask list # what is waiting
43
+ awask answer <id> ship # pick an option
44
+ awask steer <id> "do X first" # free-form guidance, card stays open
45
+
46
+ To make cards fire automatically inside Claude Code — including carrying your
47
+ answer back into the running session — install the hooks::
48
+
49
+ awask install-hooks
50
+
51
+ See ``awask install-hooks --help`` for what it writes and where.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ from awask.store import (
57
+ STATUS_ANSWERED,
58
+ STATUS_CANCELLED,
59
+ STATUS_EXPIRED,
60
+ STATUS_OPEN,
61
+ DecisionCard,
62
+ DecisionOption,
63
+ DecisionStore,
64
+ get_store,
65
+ )
66
+
67
+ __version__ = "0.1.0"
68
+
69
+ __all__ = [
70
+ "STATUS_ANSWERED",
71
+ "STATUS_CANCELLED",
72
+ "STATUS_EXPIRED",
73
+ "STATUS_OPEN",
74
+ "DecisionCard",
75
+ "DecisionOption",
76
+ "DecisionStore",
77
+ "__version__",
78
+ "get_store",
79
+ ]
awask/_doctor.py ADDED
@@ -0,0 +1,139 @@
1
+ """Stack-aware `doctor` for awask.
2
+
3
+ GENERATED BY gen_aw_doctor.py -- DO NOT EDIT.
4
+ Regenerate it with the generator named above; a hand-edit here is reverted by
5
+ the next run and fails the parity gate.
6
+
7
+ Why a doctor exists at all: the aw* bricks are designed to COMPOSE, so the
8
+ interesting failures live BETWEEN them. "awask is installed" is not the useful
9
+ fact -- "awask is installed and the thing it pairs with is not" is. This reports
10
+ the whole stack, not just itself.
11
+
12
+ stdlib only, on purpose: a diagnostic that cannot run because a dependency is
13
+ missing is worthless precisely when you need it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import os
19
+ import shutil
20
+ import sys
21
+
22
+ #: Frozen from AitherOS/config/ecosystem.yaml at generation time. A shipped
23
+ #: package cannot read the registry, and a doctor that guessed at the family
24
+ #: would go stale in silence. Regenerate to update.
25
+ SELF = 'awask'
26
+ FAMILY = ['awbac', 'awbrowse', 'awdit', 'awevolve', 'awfind', 'awgit', 'awgraph', 'awiam', 'awkno', 'awm', 'awmail', 'awnboard', 'awnest', 'awnet', 'awnode', 'awpredict', 'awprism', 'awreason', 'awrecover', 'awrecurse', 'awrelay', 'awrena', 'awrepl', 'awresearch', 'awrise', 'awrun', 'awseal', 'awshare', 'awtunnel', 'awwall']
27
+ PAIRS_WITH = ['adk', 'awmail', 'awnest', 'awrelay']
28
+
29
+ #: This brick's OWN config, read out of its source at generation time.
30
+ #: ENV_REQUIRED is `os.environ["X"]` -- absent, that is a KeyError the moment
31
+ #: the line runs. ENV_OPTIONAL is `os.getenv("X")`, which returns None and lets
32
+ #: the caller cope. Only this brick's namespace is listed: reporting the
33
+ #: platform-wide vars it also touches would be noise, and a doctor that floods
34
+ #: gets ignored.
35
+ ENV_REQUIRED = []
36
+ ENV_OPTIONAL = ['AWASK_NOTIFY_COOLDOWN', 'AWASK_NOTIFY_TTL', 'AWASK_STOP_WAIT']
37
+
38
+
39
+ def _installed(mod: str) -> "str | None":
40
+ """Version if importable, else None. Never raises -- a broken sibling must
41
+ not take the diagnostic down with it."""
42
+ try:
43
+ if importlib.util.find_spec(mod) is None:
44
+ return None
45
+ except (ImportError, ValueError):
46
+ return None
47
+ try:
48
+ from importlib.metadata import PackageNotFoundError, version
49
+ try:
50
+ return version(mod)
51
+ except PackageNotFoundError:
52
+ return "installed"
53
+ except Exception:
54
+ return "installed"
55
+
56
+
57
+ def report(out=None) -> int:
58
+ """Print the stack picture. 0 = this brick and its pairs are present."""
59
+ out = out or sys.stdout
60
+ print(f"{SELF} doctor", file=out)
61
+
62
+ mine = _installed(SELF)
63
+ print(f" self {SELF} {mine or 'NOT IMPORTABLE'}", file=out)
64
+ shim = shutil.which(SELF)
65
+ print(f" command {shim or 'not on PATH'}", file=out)
66
+
67
+ # The stack. Siblings this brick pairs with are called out separately,
68
+ # because a missing pair is a REASON, while a missing unrelated brick is
69
+ # just a fact about your machine.
70
+ missing_pairs, present = [], []
71
+ for name in FAMILY:
72
+ v = _installed(name)
73
+ if v:
74
+ present.append(name)
75
+ elif name in PAIRS_WITH:
76
+ missing_pairs.append(name)
77
+ print(f" stack {len(present)}/{len(FAMILY)} aw* packages installed",
78
+ file=out)
79
+ if present:
80
+ print(f" {' '.join(sorted(present))}", file=out)
81
+
82
+ missing_req = [v for v in ENV_REQUIRED if not os.environ.get(v)]
83
+ if ENV_REQUIRED or ENV_OPTIONAL:
84
+ have = sum(1 for v in ENV_REQUIRED + ENV_OPTIONAL if os.environ.get(v))
85
+ total = len(ENV_REQUIRED) + len(ENV_OPTIONAL)
86
+ print(f" config {have}/{total} of this brick's own vars set", file=out)
87
+ if missing_req:
88
+ # Not a preference. os.environ[...] raises the moment it runs.
89
+ print(f" MISSING REQUIRED: {' '.join(missing_req)}", file=out)
90
+
91
+ local = _local_checks()
92
+ for line in local:
93
+ print(f" {line}", file=out)
94
+
95
+ if mine is None:
96
+ print(f"\nverdict: {SELF} itself is not importable. Reinstall it before "
97
+ f"anything else here means much.", file=out)
98
+ return 1
99
+ if missing_req:
100
+ print(f"\nverdict: {SELF} is missing required config "
101
+ f"({', '.join(missing_req)}). Those are read with os.environ[...], "
102
+ f"so the code path that needs them raises rather than degrades.",
103
+ file=out)
104
+ return 1
105
+ if missing_pairs:
106
+ print(f"\nverdict: {SELF} works, but pairs with "
107
+ f"{', '.join(sorted(missing_pairs))} which "
108
+ f"{'is' if len(missing_pairs) == 1 else 'are'} not installed. "
109
+ f"That is a capability you are missing, not an error.", file=out)
110
+ return 0
111
+ print(f"\nverdict: {SELF} and everything it pairs with are present.", file=out)
112
+ return 0
113
+
114
+
115
+ def _local_checks() -> "list[str]":
116
+ """Per-brick checks, if this package defines them.
117
+
118
+ Kept as a HOOK rather than generated guesses: the generator knows the family
119
+ from the registry, but it does not know what awask needs at runtime, and a
120
+ doctor that invented config requirements would be confidently wrong. A
121
+ package supplies `_doctor_local()` returning display lines; absent, the
122
+ stack picture above still stands on its own.
123
+ """
124
+ try:
125
+ mod = importlib.import_module(f"{SELF}.doctor_local")
126
+ except Exception:
127
+ return []
128
+ try:
129
+ return list(mod._doctor_local())
130
+ except Exception as exc: # noqa: BLE001
131
+ return [f"local checks raised {type(exc).__name__}: {exc}"]
132
+
133
+
134
+ def main(argv: "list[str] | None" = None) -> int:
135
+ return report()
136
+
137
+
138
+ if __name__ == "__main__":
139
+ raise SystemExit(main())
awask/agent_tools.py ADDED
@@ -0,0 +1,473 @@
1
+ # GENERATED from the decisions module by the awask package generator.
2
+ # Do not edit here: regenerate instead, or the next run reverts you.
3
+ # Edit the SOURCE, then regenerate. An edit made here is reverted by the next
4
+ # regeneration and fails the AWK001 parity gate.
5
+ """Decision cards as first-class ADK agent tools.
6
+
7
+ Until now this whole channel was Claude-Code-shaped: a CLI an agent shelled out
8
+ to, plus a Stop hook. An ADK agent — Lyra, Demiurge, a customer's `awdk`
9
+ agent on their own laptop — had no way to reach its owner at all except
10
+ ``escalate_to_human``, which in standalone mode wrote a log line and returned
11
+ ``"status": "logged_locally"``. Nothing raised, nothing notified, nobody saw it.
12
+ That is the silent-no-op pattern (`security-review-patterns.md` §5) sitting in
13
+ the one tool whose entire job is "get a human".
14
+
15
+ So the primitives are tools now, and every ADK agent gets them:
16
+
17
+ * ``ask_human`` — raise a card and (optionally) BLOCK on it, with a deadline and
18
+ a declared default so the agent is never stuck forever.
19
+ * ``check_human`` — non-blocking: has the owner answered, or typed anything at
20
+ this card since I last looked?
21
+ * ``list_my_cards`` / ``withdraw_card`` — an agent that resolves its own question
22
+ must withdraw the card, or it trains the owner to ignore cards.
23
+
24
+ Two properties are inherited from the store and must not be re-implemented here:
25
+ a card without a stated default is REFUSED, and an answer is delivered back to
26
+ the raising session rather than merely recorded. Both are what make a card safe
27
+ to ignore and worth answering respectively.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import json
34
+ import os
35
+ import time
36
+ from typing import Any
37
+
38
+ from awask.store import (
39
+ CLOSED_STATUSES,
40
+ DecisionCard,
41
+ DecisionError,
42
+ DecisionOption,
43
+ DecisionSource,
44
+ console_tab_title,
45
+ get_store,
46
+ )
47
+
48
+ #: An agent that blocks forever is a hung agent. Any ``wait_seconds`` above this
49
+ #: is clamped, and the clamp is REPORTED in the result rather than applied
50
+ #: quietly — an agent that thinks it waited an hour and actually waited ten
51
+ #: minutes will draw the wrong conclusion from a timeout.
52
+ MAX_WAIT_SECONDS = float(os.getenv("AITHER_DECISIONS_MAX_WAIT", "3600"))
53
+
54
+ POLL_SECONDS = 1.0
55
+
56
+
57
+ def _agent_name() -> str:
58
+ return os.getenv("AITHER_AGENT_NAME", "").strip() or "adk-agent"
59
+
60
+
61
+ def _session_id() -> str:
62
+ return (os.getenv("AITHER_SESSION_ID", "").strip()
63
+ or os.getenv("CLAUDE_SESSION_ID", "").strip())
64
+
65
+
66
+ def parse_options(raw: Any) -> list[DecisionOption]:
67
+ """Accept the three shapes an LLM actually emits, and refuse the rest.
68
+
69
+ A tool argument arrives as whatever the model decided to send: a JSON array
70
+ of objects, a JSON array of ``"key|Label|consequence"`` strings, or one
71
+ newline-separated string. Guessing badly here produces a card whose options
72
+ are nonsense while every log line says success — the exact failure the pipe
73
+ separator was introduced for in the CLI.
74
+ """
75
+ if raw in (None, "", []):
76
+ return []
77
+ value = raw
78
+ if isinstance(value, str):
79
+ text = value.strip()
80
+ if text.startswith("["):
81
+ try:
82
+ value = json.loads(text)
83
+ except ValueError as exc:
84
+ raise DecisionError(f"options is not valid JSON: {exc}") from exc
85
+ else:
86
+ value = [line for line in text.splitlines() if line.strip()]
87
+ if isinstance(value, dict):
88
+ value = [value]
89
+ if not isinstance(value, list):
90
+ raise DecisionError(f"options must be a list, got {type(raw).__name__}")
91
+
92
+ out: list[DecisionOption] = []
93
+ for index, entry in enumerate(value, start=1):
94
+ if isinstance(entry, dict):
95
+ key = str(entry.get("key") or index).strip()
96
+ out.append(DecisionOption(
97
+ key=key,
98
+ label=str(entry.get("label") or key).strip(),
99
+ consequence=str(entry.get("consequence") or "").strip(),
100
+ recommended=bool(entry.get("recommended")),
101
+ ))
102
+ continue
103
+ parts = str(entry).split("|")
104
+ key = parts[0].strip() or str(index)
105
+ out.append(DecisionOption(
106
+ key=key,
107
+ label=(parts[1].strip() if len(parts) > 1 else key),
108
+ consequence=(parts[2].strip() if len(parts) > 2 else ""),
109
+ ))
110
+ return out
111
+
112
+
113
+ def raise_card(
114
+ title: str,
115
+ *,
116
+ summary: str = "",
117
+ detail: str = "",
118
+ options: Any = None,
119
+ facts: Any = None,
120
+ kind: str = "decision",
121
+ urgency: str = "normal",
122
+ recommend: str = "",
123
+ default: str = "",
124
+ deadline_seconds: float = 0.0,
125
+ agent: str = "",
126
+ session_id: str = "",
127
+ cwd: str = "",
128
+ ) -> DecisionCard:
129
+ """Raise a card from library code. The synchronous core of every tool here."""
130
+ from awask.notify import notify
131
+
132
+ parsed = parse_options(options)
133
+ if recommend:
134
+ wanted = recommend.strip().lower()
135
+ for option in parsed:
136
+ if option.key.lower() == wanted:
137
+ option.recommended = True
138
+ default_key = (default or "").strip()
139
+ if not default_key and parsed:
140
+ default_key = next((o.key for o in parsed if o.recommended), parsed[0].key)
141
+
142
+ fact_list: list[str]
143
+ if isinstance(facts, str):
144
+ fact_list = [line for line in facts.splitlines() if line.strip()]
145
+ else:
146
+ fact_list = [str(f) for f in (facts or []) if str(f).strip()]
147
+
148
+ try:
149
+ from awask import winproc
150
+
151
+ session_pid = winproc.resolve_owner_pid(os.getpid())
152
+ except (ImportError, OSError):
153
+ session_pid = os.getpid()
154
+
155
+ store = get_store()
156
+ card = store.create(DecisionCard(
157
+ id="",
158
+ title=(title or "").strip(),
159
+ summary=summary.strip(),
160
+ detail=detail.strip(),
161
+ kind=kind,
162
+ urgency=urgency,
163
+ options=parsed,
164
+ default_key=default_key,
165
+ facts=fact_list,
166
+ source=DecisionSource(
167
+ session_id=session_id or _session_id(),
168
+ agent=agent or _agent_name(),
169
+ cwd=cwd or os.getcwd(),
170
+ session_pid=session_pid,
171
+ tab_title=console_tab_title(),
172
+ ),
173
+ deadline=(time.time() + deadline_seconds) if deadline_seconds > 0 else None,
174
+ ))
175
+ notify(card, store)
176
+ return card
177
+
178
+
179
+ def _card_state(card: DecisionCard) -> dict[str, Any]:
180
+ chosen = card.option(card.answer or "")
181
+ return {
182
+ "id": card.id,
183
+ "status": card.status,
184
+ "answer": card.answer,
185
+ "answer_label": chosen.label if chosen else "",
186
+ "answer_consequence": chosen.consequence if chosen else "",
187
+ "answer_note": card.answer_note or "",
188
+ "owner_said": [n.text for n in card.notes],
189
+ "title": card.title,
190
+ }
191
+
192
+
193
+ # ── the tools ───────────────────────────────────────────────────────────────────
194
+
195
+
196
+ async def ask_human(
197
+ title: str,
198
+ summary: str = "",
199
+ options: str = "",
200
+ facts: str = "",
201
+ urgency: str = "normal",
202
+ default: str = "",
203
+ recommend: str = "",
204
+ wait_seconds: float = 0,
205
+ deadline_seconds: float = 0,
206
+ ) -> str:
207
+ """Ask the owner a question they can answer from a popup, phone or terminal.
208
+
209
+ Use this ONLY when all three hold: the readings lead to materially different
210
+ work, a wrong guess costs real work to undo, and you cannot settle it
211
+ yourself by reading, measuring or taking the conventional default. Otherwise
212
+ decide it, say so in one line and keep going — a card is an interruption,
213
+ and an agent that raises them freely destroys the channel for the one that
214
+ mattered.
215
+
216
+ NEVER for ordinary conversation. A greeting, small talk, or "how should I
217
+ reply to this message" is not a decision — just answer it yourself in plain
218
+ text. Raising a card for "the user said hello, how do I respond?" wastes the
219
+ owner's attention and, if the card is malformed, can burn the whole turn
220
+ with nothing ever said back to them.
221
+
222
+ If a call is REJECTED (this returns `{"ok": false, "error": ...}`), read the
223
+ error and change what it names — do not resend the same arguments. The
224
+ loop guard will BLOCK an identical retry after a couple of attempts, and
225
+ a blocked call still counts as a turn you never answered the user in.
226
+
227
+ Args:
228
+ title: One line, the whole ask. If it needs two, it is two cards.
229
+ summary: One or two lines of what the owner needs in order to decide.
230
+ options: The choices. JSON array of {key,label,consequence}, or one
231
+ "key|Label|what happens if you pick it" per line. A consequence is
232
+ what makes a card answerable without reading the code.
233
+ facts: What you MEASURED, one per line — never a guess.
234
+ urgency: low | normal | high | critical (exact string — not a number).
235
+ default: The option's KEY (not its label/text) applied if the owner
236
+ never answers. REQUIRED for a real decision; the store refuses a
237
+ card without one. If you passed `options` as plain strings with no
238
+ "|", the key IS that string verbatim — match it exactly.
239
+ recommend: The option key (not the label) you would pick.
240
+ wait_seconds: Block until answered, up to this long. 0 returns at once
241
+ and you poll with check_human.
242
+ deadline_seconds: Apply the default automatically after this long.
243
+ """
244
+ try:
245
+ card = raise_card(
246
+ title, summary=summary, options=options, facts=facts, urgency=urgency,
247
+ default=default, recommend=recommend, deadline_seconds=deadline_seconds,
248
+ )
249
+ except DecisionError as exc:
250
+ # Refusals here are the store enforcing "a card must be decidable".
251
+ # Reported as a failure the agent can fix, never swallowed.
252
+ return json.dumps({"ok": False, "error": str(exc)})
253
+
254
+ wait = min(float(wait_seconds or 0), MAX_WAIT_SECONDS)
255
+ clamped = float(wait_seconds or 0) > MAX_WAIT_SECONDS
256
+ if wait <= 0:
257
+ return json.dumps({"ok": True, "waited": False, **_card_state(card)})
258
+
259
+ store = get_store()
260
+ deadline = time.time() + wait
261
+ while time.time() < deadline:
262
+ await asyncio.sleep(POLL_SECONDS)
263
+ current = store.get(card.id)
264
+ if current is None:
265
+ return json.dumps({"ok": False, "error": "the card disappeared while waiting",
266
+ "id": card.id})
267
+ if current.status in CLOSED_STATUSES or current.notes:
268
+ return json.dumps({"ok": True, "waited": True, **_card_state(current)})
269
+
270
+ return json.dumps({
271
+ "ok": True, "waited": True, "timed_out": True,
272
+ "clamped_to_seconds": MAX_WAIT_SECONDS if clamped else None,
273
+ "guidance": (
274
+ f"No answer in {int(wait)}s. The card is still open and the owner can "
275
+ f"answer it later. Proceed on the declared default "
276
+ f"('{card.default_key}') and say that you did."
277
+ ),
278
+ **_card_state(card),
279
+ })
280
+
281
+
282
+ async def check_human(card_id: str = "") -> str:
283
+ """Has the owner answered, or typed anything at, a card I raised?
284
+
285
+ Non-blocking. Call it between steps of long work so a mid-flight steer ("do
286
+ the other one first") reaches you before you finish the wrong thing.
287
+
288
+ Args:
289
+ card_id: The card to check. Empty checks every card this session raised.
290
+ """
291
+ store = get_store()
292
+ if card_id:
293
+ try:
294
+ card = store.get(card_id)
295
+ except DecisionError as exc:
296
+ return json.dumps({"ok": False, "error": str(exc)})
297
+ if card is None:
298
+ return json.dumps({"ok": False, "error": f"no such card: {card_id}"})
299
+ return json.dumps({"ok": True, **_card_state(card)})
300
+
301
+ session = _session_id()
302
+ cards = store.list(status=None, session_id=session) if session else store.list()
303
+ return json.dumps({
304
+ "ok": True,
305
+ "cards": [_card_state(c) for c in cards],
306
+ "open": sum(1 for c in cards if c.is_open),
307
+ })
308
+
309
+
310
+ async def list_my_cards(include_closed: bool = False) -> str:
311
+ """What is waiting on the owner right now.
312
+
313
+ Args:
314
+ include_closed: Also list answered/expired/withdrawn cards.
315
+ """
316
+ store = get_store()
317
+ cards = store.list(status=None if include_closed else "open")
318
+ return json.dumps({
319
+ "ok": True,
320
+ "count": len(cards),
321
+ "cards": [
322
+ {**_card_state(c), "urgency": c.urgency,
323
+ "age_seconds": round(c.age_seconds, 1),
324
+ "agent": c.source.agent}
325
+ for c in cards
326
+ ],
327
+ })
328
+
329
+
330
+ async def withdraw_card(card_id: str, reason: str = "") -> str:
331
+ """Withdraw a card you no longer need answered.
332
+
333
+ Do this the moment you resolve the question yourself. A card left open after
334
+ the agent has moved on is how the owner learns that cards can be ignored.
335
+
336
+ Args:
337
+ card_id: The card to withdraw.
338
+ reason: Why it is no longer needed.
339
+ """
340
+ try:
341
+ card = get_store().cancel(card_id, note=reason or "the agent resolved it")
342
+ except DecisionError as exc:
343
+ return json.dumps({"ok": False, "error": str(exc)})
344
+ return json.dumps({"ok": True, "id": card.id, "status": card.status})
345
+
346
+
347
+ #: Registered as the ``decisions`` category by ``adk.builtin_tools``.
348
+ DECISION_TOOLS = [ask_human, check_human, list_my_cards, withdraw_card]
349
+
350
+
351
+ def _self_test() -> int:
352
+ """Prove the option parsing, the refusals and the wait/timeout all behave."""
353
+ import tempfile
354
+ from pathlib import Path
355
+
356
+ problems: list[str] = []
357
+
358
+ def check(name: str, condition: bool, detail: str = "") -> None:
359
+ print(f" {'ok ' if condition else 'FAIL'} {name}{'' if condition else ' ' + detail}")
360
+ if not condition:
361
+ problems.append(name)
362
+
363
+ parsed = parse_options('[{"key":"a","label":"A","consequence":"c","recommended":true}]')
364
+ check("parses JSON objects", len(parsed) == 1 and parsed[0].recommended)
365
+ parsed = parse_options('["a|Keep it|nothing changes","b|Fix it|needs a restart"]')
366
+ check("parses a JSON array of pipe strings",
367
+ len(parsed) == 2 and parsed[1].consequence == "needs a restart")
368
+ parsed = parse_options("a|Keep it|nothing changes\nb|Fix it|needs a restart")
369
+ check("parses newline-separated pipe strings", len(parsed) == 2)
370
+ parsed = parse_options(r"repoint|Repoint to C:\Projects\Example|drops the D: dep")
371
+ check("an option can carry a Windows path",
372
+ parsed[0].label == r"Repoint to C:\Projects\Example", parsed[0].label)
373
+ try:
374
+ parse_options("[not json")
375
+ check("refuses malformed JSON", False, "it was accepted")
376
+ except DecisionError:
377
+ check("refuses malformed JSON", True)
378
+
379
+ with tempfile.TemporaryDirectory() as tmp:
380
+ os.environ["AITHER_DECISIONS_DIR"] = str(Path(tmp) / "cards")
381
+ os.environ["AITHER_STEER_DIR"] = str(Path(tmp) / "steer")
382
+ os.environ["AITHER_DECISIONS_POPUP"] = "0" # no window in a self-test
383
+ # Never type into a REAL console from a self-test: the temp card still
384
+ # carries the live session's pid, so with console input armed the
385
+ # delivery typed "Answer: b ... do it tonight" into the OWNER's
386
+ # terminal (observed live 2026-08-25).
387
+ os.environ["AITHER_DECISIONS_CONSOLE_INPUT"] = "0"
388
+ os.environ["AITHER_SESSION_ID"] = "agent-selftest"
389
+ import awask.store as store_module
390
+
391
+ store_module._STORE = None # noqa: SLF001 - rebind the singleton at the temp dir
392
+
393
+ bare = json.loads(asyncio.run(ask_human("no options at all")))
394
+ check("refuses an optionless decision card",
395
+ not bare.get("ok") and "options" in bare.get("error", ""), str(bare))
396
+
397
+ raised = json.loads(asyncio.run(ask_human(
398
+ "Keep the 5090 in the pool while the DGX rebuilds?",
399
+ summary="Both are defensible and they diverge by days of work.",
400
+ options="keep|Keep it pooled|throughput holds, DGX rebuild is slower\n"
401
+ "drain|Drain the 5090|rebuild finishes tonight, pool loses 40%",
402
+ facts="pool throughput measured at 41 tok/s\nDGX rebuild ETA 6h",
403
+ recommend="keep", default="keep",
404
+ )))
405
+ check("raises a real card", raised.get("ok") and raised["id"].startswith("d-"),
406
+ str(raised))
407
+ check("does not wait when not asked", raised.get("waited") is False)
408
+
409
+ card_id = raised["id"]
410
+ listed = json.loads(asyncio.run(list_my_cards()))
411
+ check("lists the open card", listed["count"] == 1)
412
+
413
+ checked = json.loads(asyncio.run(check_human(card_id)))
414
+ check("check_human sees it open", checked["status"] == "open")
415
+
416
+ # A wait that nobody answers must TIME OUT with guidance, never hang and
417
+ # never claim an answer.
418
+ started = time.time()
419
+ timed = json.loads(asyncio.run(ask_human(
420
+ "will not be answered", options="a|A|nothing\nb|B|nothing",
421
+ default="a", wait_seconds=2,
422
+ )))
423
+ check("a wait times out rather than hanging", time.time() - started < 20)
424
+ check("the timeout says what to do next",
425
+ timed.get("timed_out") and "default" in timed.get("guidance", ""), str(timed))
426
+
427
+ # An answer that lands WHILE the agent is waiting must be returned by the
428
+ # wait itself — not merely recorded for a later poll. That is the whole
429
+ # difference between a blocking ask and a survey.
430
+ store = store_module.get_store()
431
+ subject = "answered while the agent waits"
432
+
433
+ async def answer_midflight() -> str:
434
+ for _ in range(100):
435
+ await asyncio.sleep(0.2)
436
+ match = [c for c in store.list() if c.title == subject]
437
+ if match:
438
+ store.answer(match[0].id, "b", note="do it tonight", via="selftest")
439
+ return match[0].id
440
+ return ""
441
+
442
+ async def race():
443
+ answering = asyncio.create_task(answer_midflight())
444
+ waited = await ask_human(
445
+ subject, options="a|A|nothing\nb|B|nothing", default="a", wait_seconds=15,
446
+ )
447
+ await answering
448
+ return json.loads(waited)
449
+
450
+ raced = asyncio.run(race())
451
+ check("a blocking wait returns the answer it was waiting for",
452
+ raced.get("answer") == "b" and not raced.get("timed_out"), str(raced))
453
+ check("the owner's note comes back with it",
454
+ raced.get("answer_note") == "do it tonight", str(raced))
455
+ after = json.loads(asyncio.run(check_human(card_id)))
456
+ check("an unanswered card is still open afterwards", after["status"] == "open")
457
+
458
+ withdrawn = json.loads(asyncio.run(withdraw_card(timed["id"], "resolved it myself")))
459
+ check("withdrawing works", withdrawn.get("ok") and withdrawn["status"] == "cancelled")
460
+ asyncio.run(withdraw_card(card_id, "done with it"))
461
+ gone = json.loads(asyncio.run(list_my_cards()))
462
+ check("a withdrawn card leaves the open list", gone["count"] == 0, str(gone))
463
+
464
+ print()
465
+ if problems:
466
+ print(f"agent-tools self-test FAILED - {', '.join(problems)}")
467
+ return 1
468
+ print("agent-tools self-test passed - cards raise, wait, time out and withdraw")
469
+ return 0
470
+
471
+
472
+ if __name__ == "__main__":
473
+ raise SystemExit(_self_test())