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 +79 -0
- awask/_doctor.py +139 -0
- awask/agent_tools.py +473 -0
- awask/channels.py +600 -0
- awask/cli.py +763 -0
- awask/context.py +503 -0
- awask/entry.py +36 -0
- awask/hooks/__init__.py +0 -0
- awask/hooks/awask_mailbox_drain.py +242 -0
- awask/hooks/awask_notification_card.py +331 -0
- awask/hooks/stop_awask_cards.py +203 -0
- awask/install.py +302 -0
- awask/notify.py +376 -0
- awask/popup.py +1740 -0
- awask/presence.py +166 -0
- awask/render.py +305 -0
- awask/steerback.py +221 -0
- awask/store.py +873 -0
- awask/terminal.py +557 -0
- awask/winproc.py +367 -0
- awask-0.1.0.dist-info/METADATA +368 -0
- awask-0.1.0.dist-info/RECORD +25 -0
- awask-0.1.0.dist-info/WHEEL +4 -0
- awask-0.1.0.dist-info/entry_points.txt +2 -0
- awask-0.1.0.dist-info/licenses/LICENSE +202 -0
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())
|