keywright-router 0.0.1__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.
- keywright_router/__init__.py +9 -0
- keywright_router/__main__.py +83 -0
- keywright_router/access.py +114 -0
- keywright_router/config.py +241 -0
- keywright_router/kw.py +99 -0
- keywright_router/router.py +331 -0
- keywright_router/rules.py +124 -0
- keywright_router/state.py +67 -0
- keywright_router-0.0.1.dist-info/METADATA +140 -0
- keywright_router-0.0.1.dist-info/RECORD +15 -0
- keywright_router-0.0.1.dist-info/WHEEL +4 -0
- keywright_router-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_router-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_router-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_router-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Keywright router: watches a mailbox through Keywright and acts on what arrives.
|
|
2
|
+
|
|
3
|
+
The router is an ordinary Keywright agent. It holds no mail password and no provider key:
|
|
4
|
+
it reads and sends mail through the brokered `email` MCP server, asks the LLM gateway only
|
|
5
|
+
when a rule needs a label or a draft, and every call it makes is checked against the rules
|
|
6
|
+
the human set for its token. Routing itself is plain boolean logic over the message.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""keywright-router run|check|dry-run [--config PATH]
|
|
2
|
+
|
|
3
|
+
run poll forever (the container's command)
|
|
4
|
+
check validate the rules file and exit
|
|
5
|
+
access print the standing access the rules need (as Keywright qualifies the calls) and ask
|
|
6
|
+
for what is missing in one request; approve it with Always allow
|
|
7
|
+
dry-run one pass over today's unread mail: route each message and print the calls it would
|
|
8
|
+
make, without making them or recording anything. Reads and labels still go through
|
|
9
|
+
Keywright, so they are audited like any other call.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from .config import ConfigError, load
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: list[str] | None = None) -> int:
|
|
24
|
+
ap = argparse.ArgumentParser(prog="keywright-router", description=__doc__,
|
|
25
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
26
|
+
ap.add_argument("command", choices=["run", "check", "access", "dry-run"])
|
|
27
|
+
ap.add_argument("--config", default="/config/rules.yaml")
|
|
28
|
+
ap.add_argument("--log-level", default="INFO")
|
|
29
|
+
a = ap.parse_args(argv)
|
|
30
|
+
logging.basicConfig(level=a.log_level.upper(), format="%(asctime)s %(levelname)s %(message)s")
|
|
31
|
+
try:
|
|
32
|
+
cfg = load(a.config)
|
|
33
|
+
except ConfigError as e:
|
|
34
|
+
print(f"config error: {e}", file=sys.stderr)
|
|
35
|
+
return 2
|
|
36
|
+
if a.command == "check":
|
|
37
|
+
print(f"ok: {len(cfg.rules)} rules, {len(cfg.unmatched)} unmatched actions, "
|
|
38
|
+
f"llm {'used (' + str(cfg.model) + ')' if cfg.uses_llm else 'not used'}")
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
from .router import Router
|
|
42
|
+
if a.command == "access":
|
|
43
|
+
return asyncio.run(_access(cfg))
|
|
44
|
+
if a.command == "run":
|
|
45
|
+
asyncio.run(Router(cfg).run_forever())
|
|
46
|
+
return 0
|
|
47
|
+
return asyncio.run(_dry_run(cfg))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _access(cfg) -> int:
|
|
51
|
+
from . import access
|
|
52
|
+
from .kw import Keywright
|
|
53
|
+
|
|
54
|
+
status, rid, patterns = await access.ensure(Keywright(cfg.kw_url, cfg.kw_token, cfg.session), cfg)
|
|
55
|
+
for p in patterns:
|
|
56
|
+
print(p)
|
|
57
|
+
print(f"{status}{' ' + rid if rid else ''}", file=sys.stderr)
|
|
58
|
+
return 0 if status in ("approved", "pending") else 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _dry_run(cfg) -> int:
|
|
62
|
+
from .kw import Pending
|
|
63
|
+
from .router import Router
|
|
64
|
+
|
|
65
|
+
router = Router(cfg)
|
|
66
|
+
for meta in await router.unread():
|
|
67
|
+
msg = await router.fetch(str(meta["email_id"]))
|
|
68
|
+
if msg is None:
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
job = await router.plan(msg)
|
|
72
|
+
except Pending as p:
|
|
73
|
+
print(f"{msg.key}: needs approval first ({p.request_id})")
|
|
74
|
+
continue
|
|
75
|
+
seen = " (already handled)" if router.store.has(msg.key) else ""
|
|
76
|
+
print(f"\n{msg.key}{seen} {msg.subject!r} from {msg.sender_address}")
|
|
77
|
+
for s in job.steps:
|
|
78
|
+
print(" ", json.dumps({k: v for k, v in s.items() if k != "reason"})[:400])
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The standing access the rules need, asked for in one request instead of rule by rule.
|
|
2
|
+
|
|
3
|
+
The paths are built the way Keywright qualifies the calls themselves (the same `classify_mcp`,
|
|
4
|
+
the resource's own argument keys), so an "Always allow" on the request is exactly as wide as the
|
|
5
|
+
rules file and no wider. Two things are left out on purpose:
|
|
6
|
+
|
|
7
|
+
- a send or forward whose resource does not qualify calls by `recipients`: allowing the tool
|
|
8
|
+
would allow any recipient, so those calls keep asking one by one;
|
|
9
|
+
- replies (the recipient is the sender of each message) and templated argument values (they
|
|
10
|
+
differ per message): the paths that would name them are dropped, so the human sees them per call.
|
|
11
|
+
|
|
12
|
+
Model access is never part of it: an LLM approval is a timed lease with a spend ceiling, and a
|
|
13
|
+
standing model rule is made in the UI.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from keywright.classify import classify_mcp
|
|
22
|
+
|
|
23
|
+
from .config import PLACEHOLDER, Config
|
|
24
|
+
from .kw import Keywright
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("keywright_router")
|
|
27
|
+
|
|
28
|
+
RECIPIENT_KEYS = ("recipients", "cc", "bcc")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def needed(cfg: Config, arg_keys: dict[str, list[str]]) -> list[str]:
|
|
32
|
+
"""Every path the rules will call with fixed arguments, qualified by each server's argument keys."""
|
|
33
|
+
acct = {"account_name": cfg.account}
|
|
34
|
+
calls: list[tuple[str, str, dict[str, Any]]] = [
|
|
35
|
+
(cfg.server, "list_emails_metadata", acct),
|
|
36
|
+
(cfg.server, "get_emails_content", acct),
|
|
37
|
+
]
|
|
38
|
+
actions = [a for r in cfg.rules for a in r.do] + list(cfg.unmatched)
|
|
39
|
+
if actions:
|
|
40
|
+
calls.append((cfg.server, "mark_emails_as_read", acct))
|
|
41
|
+
if cfg.done_folder or any("move" in a for a in actions):
|
|
42
|
+
calls.append((cfg.server, "move_emails", acct))
|
|
43
|
+
email_keys = set(arg_keys.get(cfg.server) or [])
|
|
44
|
+
for action in actions:
|
|
45
|
+
kind, spec = next(iter(action.items()))
|
|
46
|
+
if kind in ("forward", "send"):
|
|
47
|
+
if "recipients" not in email_keys:
|
|
48
|
+
log.info("%s is not qualified by recipients on %s: %s calls will ask each time",
|
|
49
|
+
kind, cfg.server, kind)
|
|
50
|
+
continue
|
|
51
|
+
tool = "forward_email" if kind == "forward" else "send_email"
|
|
52
|
+
calls.append((cfg.server, tool, {**acct, "recipients": list(spec["to"])}))
|
|
53
|
+
elif kind == "mcp":
|
|
54
|
+
server, tool = spec["tool"].split("__", 1)
|
|
55
|
+
raw, fixed = spec.get("args", {}), _literal(spec.get("args", {})) or {}
|
|
56
|
+
for key in arg_keys.get(server) or []:
|
|
57
|
+
if _lookup(raw, key) is not None and _lookup(fixed, key) is None:
|
|
58
|
+
log.warning("%s qualifies %s by %s, which this rule fills from the message: "
|
|
59
|
+
"that call will ask every time", server, tool, key)
|
|
60
|
+
calls.append((server, tool, fixed))
|
|
61
|
+
out: list[str] = []
|
|
62
|
+
for server, tool, args in calls:
|
|
63
|
+
keys = arg_keys.get(server)
|
|
64
|
+
if keys is None:
|
|
65
|
+
log.warning("no MCP resource named %r in Keywright; skipping %s", server, tool)
|
|
66
|
+
continue
|
|
67
|
+
for p in classify_mcp(server, tool, args, keys):
|
|
68
|
+
if p not in out:
|
|
69
|
+
out.append(p)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _lookup(args: dict[str, Any], key: str) -> Any:
|
|
74
|
+
cur: Any = args
|
|
75
|
+
for part in key.split("."):
|
|
76
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
77
|
+
return None
|
|
78
|
+
cur = cur[part]
|
|
79
|
+
return cur
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _literal(value: Any) -> Any:
|
|
83
|
+
"""Arguments as the human can pre-approve them: a templated value is unknown until a message
|
|
84
|
+
arrives, so it is removed and never qualifies the path."""
|
|
85
|
+
if isinstance(value, str):
|
|
86
|
+
return None if PLACEHOLDER.search(value) else value
|
|
87
|
+
if isinstance(value, dict):
|
|
88
|
+
return {k: v for k, v in ((k, _literal(v)) for k, v in value.items()) if v is not None}
|
|
89
|
+
if isinstance(value, list):
|
|
90
|
+
return [v for v in (_literal(v) for v in value) if v is not None] or None
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def argument_keys(kw: Keywright) -> dict[str, list[str]]:
|
|
95
|
+
data = await kw.call_access("access_list_resources", {})
|
|
96
|
+
out = {}
|
|
97
|
+
for r in data.get("resources") or []:
|
|
98
|
+
if r.get("kind") == "mcp":
|
|
99
|
+
out[str(r["resource"]).split("/", 1)[1]] = list(r.get("argument_keys") or [])
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def ensure(kw: Keywright, cfg: Config) -> tuple[str, str | None, list[str]]:
|
|
104
|
+
"""Ask once for whatever the rules need that is not yet allowed. Returns (status, request_id, patterns):
|
|
105
|
+
approved when nothing is missing, pending with the request to watch, or denied."""
|
|
106
|
+
patterns = needed(cfg, await argument_keys(kw))
|
|
107
|
+
if not patterns:
|
|
108
|
+
return "approved", None, []
|
|
109
|
+
data = await kw.call_access("access_request", {
|
|
110
|
+
"patterns": patterns, "wait_seconds": 0,
|
|
111
|
+
"reason": f"mail router: standing access for its {len(cfg.rules)} rule(s). Choose Always allow "
|
|
112
|
+
"to make these rules; anything else it does keeps asking per call.",
|
|
113
|
+
})
|
|
114
|
+
return str(data.get("status") or data.get("error") or "unknown"), data.get("request_id"), patterns
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""The rules file: where the mail is, which model labels it, and what to do when a rule matches.
|
|
2
|
+
|
|
3
|
+
Loaded once at start and validated completely, so a typo fails the container at boot instead
|
|
4
|
+
of on the first message that happens to reach that rule.
|
|
5
|
+
|
|
6
|
+
Recipients and tool names are literals. Message text reaches a call only through `{{field}}`
|
|
7
|
+
placeholders in free-text values (a subject, a body, a tool argument), never as an address or
|
|
8
|
+
a tool: a message cannot choose who hears about it or what runs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
PREDICATES = {"from", "to", "subject", "subject_matches", "body", "body_matches", "has_attachment", "label"}
|
|
23
|
+
COMBINATORS = {"all", "any", "not"}
|
|
24
|
+
ACTIONS = {"reply", "send", "forward", "mcp", "attention", "move", "mark_read"}
|
|
25
|
+
FIELDS = {"from", "to", "subject", "date", "body", "label", "email_id", "rule"}
|
|
26
|
+
PLACEHOLDER = re.compile(r"\{\{\s*([a-z_]+)\s*\}\}")
|
|
27
|
+
ADDRESS = re.compile(r"^[^@\s<>{}]+@[^@\s<>{}]+\.[^@\s<>{}]+$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigError(ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Rule:
|
|
36
|
+
name: str
|
|
37
|
+
when: Any
|
|
38
|
+
do: list[dict[str, Any]]
|
|
39
|
+
stop: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Config:
|
|
44
|
+
kw_url: str
|
|
45
|
+
kw_token: str
|
|
46
|
+
session: str
|
|
47
|
+
server: str
|
|
48
|
+
account: str
|
|
49
|
+
mailbox: str
|
|
50
|
+
poll_seconds: int
|
|
51
|
+
done_folder: str | None
|
|
52
|
+
since: str | None
|
|
53
|
+
gateway: str | None
|
|
54
|
+
model: str | None
|
|
55
|
+
labels: tuple[str, ...]
|
|
56
|
+
max_body_chars: int
|
|
57
|
+
rules: tuple[Rule, ...]
|
|
58
|
+
unmatched: tuple[dict[str, Any], ...]
|
|
59
|
+
state_path: Path
|
|
60
|
+
pending_hours: float
|
|
61
|
+
uses_llm: bool = field(default=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load(path: str | os.PathLike[str], env: dict[str, str] | None = None) -> Config:
|
|
65
|
+
env = dict(os.environ if env is None else env)
|
|
66
|
+
try:
|
|
67
|
+
raw = yaml.safe_load(Path(path).read_text()) or {}
|
|
68
|
+
except (OSError, yaml.YAMLError) as e:
|
|
69
|
+
raise ConfigError(f"{path}: {e}") from e
|
|
70
|
+
if not isinstance(raw, dict):
|
|
71
|
+
raise ConfigError(f"{path}: expected a mapping at the top level")
|
|
72
|
+
kw = raw.get("keywright") or {}
|
|
73
|
+
mail = raw.get("email") or {}
|
|
74
|
+
llm = raw.get("llm") or {}
|
|
75
|
+
|
|
76
|
+
token = env.get("KW_TOKEN", "")
|
|
77
|
+
if not token:
|
|
78
|
+
raise ConfigError("KW_TOKEN is not set; the router's Keywright token comes from the environment only")
|
|
79
|
+
labels = tuple(str(x) for x in llm.get("labels") or [])
|
|
80
|
+
if labels and "unclear" not in labels:
|
|
81
|
+
labels = (*labels, "unclear")
|
|
82
|
+
|
|
83
|
+
if mail.get("since"):
|
|
84
|
+
try:
|
|
85
|
+
datetime.fromisoformat(str(mail["since"]))
|
|
86
|
+
except ValueError as e:
|
|
87
|
+
raise ConfigError(f"email.since: {e} (use 2026-09-28 or 2026-09-28T09:00:00+00:00)") from e
|
|
88
|
+
rules = tuple(_rule(i, r, labels) for i, r in enumerate(raw.get("rules") or []))
|
|
89
|
+
names = [r.name for r in rules]
|
|
90
|
+
if len(set(names)) != len(names):
|
|
91
|
+
raise ConfigError("rule names must be unique")
|
|
92
|
+
unmatched = tuple(_action("unmatched", i, a) for i, a in enumerate(raw.get("unmatched") or []))
|
|
93
|
+
|
|
94
|
+
drafts = any(isinstance(spec, dict) and "llm" in spec
|
|
95
|
+
for a in [*(a for r in rules for a in r.do), *unmatched] for spec in a.values())
|
|
96
|
+
uses_llm = drafts or any(_mentions_label(r.when) for r in rules)
|
|
97
|
+
gateway, model = llm.get("gateway"), llm.get("model")
|
|
98
|
+
if uses_llm and not (gateway and model):
|
|
99
|
+
raise ConfigError("a rule uses `label` or an `llm:` draft, so llm.gateway and llm.model are required")
|
|
100
|
+
if any(_mentions_label(r.when) for r in rules) and not labels:
|
|
101
|
+
raise ConfigError("a rule tests `label`, so llm.labels must list the labels the model may choose")
|
|
102
|
+
|
|
103
|
+
return Config(
|
|
104
|
+
kw_url=env.get("KW_URL") or kw.get("url") or "http://keywright:8700",
|
|
105
|
+
kw_token=token,
|
|
106
|
+
session=str(kw.get("session") or "keywright-router"),
|
|
107
|
+
server=str(mail.get("server") or "email"),
|
|
108
|
+
account=_required(mail, "account", "email"),
|
|
109
|
+
mailbox=str(mail.get("mailbox") or "INBOX"),
|
|
110
|
+
poll_seconds=max(15, int(mail.get("poll_seconds") or 60)),
|
|
111
|
+
done_folder=mail.get("done_folder") or None,
|
|
112
|
+
since=str(mail["since"]) if mail.get("since") else None,
|
|
113
|
+
gateway=gateway,
|
|
114
|
+
model=model,
|
|
115
|
+
labels=labels,
|
|
116
|
+
max_body_chars=int(llm.get("max_body_chars") or 6000),
|
|
117
|
+
rules=rules,
|
|
118
|
+
unmatched=unmatched,
|
|
119
|
+
state_path=Path(env.get("ROUTER_STATE") or raw.get("state") or "/data/router.db"),
|
|
120
|
+
pending_hours=float(raw.get("pending_hours") or 6),
|
|
121
|
+
uses_llm=uses_llm,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _required(section: dict, key: str, where: str) -> str:
|
|
126
|
+
value = section.get(key)
|
|
127
|
+
if not value:
|
|
128
|
+
raise ConfigError(f"{where}.{key} is required")
|
|
129
|
+
return str(value)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _rule(i: int, raw: Any, labels: tuple[str, ...]) -> Rule:
|
|
133
|
+
if not isinstance(raw, dict) or not raw.get("name"):
|
|
134
|
+
raise ConfigError(f"rules[{i}]: each rule needs a name")
|
|
135
|
+
name = str(raw["name"])
|
|
136
|
+
if "when" not in raw:
|
|
137
|
+
raise ConfigError(f"rule {name}: `when` is required (`when: {{all: []}}` matches every message)")
|
|
138
|
+
_condition(name, raw["when"], labels)
|
|
139
|
+
do = raw.get("do") or []
|
|
140
|
+
if not isinstance(do, list) or not do:
|
|
141
|
+
raise ConfigError(f"rule {name}: `do` must be a non-empty list of actions")
|
|
142
|
+
return Rule(name=name, when=raw["when"], do=[_action(name, j, a) for j, a in enumerate(do)],
|
|
143
|
+
stop=bool(raw.get("stop", False)))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _condition(rule: str, cond: Any, labels: tuple[str, ...]) -> None:
|
|
147
|
+
if not isinstance(cond, dict) or not cond:
|
|
148
|
+
raise ConfigError(f"rule {rule}: a condition is a non-empty mapping, got {cond!r}")
|
|
149
|
+
for key, value in cond.items():
|
|
150
|
+
if key in ("all", "any"):
|
|
151
|
+
if not isinstance(value, list):
|
|
152
|
+
raise ConfigError(f"rule {rule}: `{key}` takes a list of conditions")
|
|
153
|
+
for c in value:
|
|
154
|
+
_condition(rule, c, labels)
|
|
155
|
+
elif key == "not":
|
|
156
|
+
_condition(rule, value, labels)
|
|
157
|
+
elif key in ("subject_matches", "body_matches"):
|
|
158
|
+
try:
|
|
159
|
+
re.compile(str(value))
|
|
160
|
+
except re.error as e:
|
|
161
|
+
raise ConfigError(f"rule {rule}: bad regex in {key}: {e}") from e
|
|
162
|
+
elif key == "has_attachment":
|
|
163
|
+
if not isinstance(value, bool):
|
|
164
|
+
raise ConfigError(f"rule {rule}: has_attachment is true or false")
|
|
165
|
+
elif key == "label":
|
|
166
|
+
wanted = value if isinstance(value, list) else [value]
|
|
167
|
+
unknown = [w for w in wanted if w not in labels]
|
|
168
|
+
if unknown:
|
|
169
|
+
raise ConfigError(f"rule {rule}: label {unknown} is not in llm.labels {list(labels)}")
|
|
170
|
+
elif key not in PREDICATES:
|
|
171
|
+
raise ConfigError(f"rule {rule}: unknown condition `{key}` "
|
|
172
|
+
f"(known: {sorted(PREDICATES | COMBINATORS)})")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _mentions_label(cond: Any) -> bool:
|
|
176
|
+
if not isinstance(cond, dict):
|
|
177
|
+
return False
|
|
178
|
+
for key, value in cond.items():
|
|
179
|
+
if key == "label":
|
|
180
|
+
return True
|
|
181
|
+
if key in ("all", "any") and any(_mentions_label(c) for c in value):
|
|
182
|
+
return True
|
|
183
|
+
if key == "not" and _mentions_label(value):
|
|
184
|
+
return True
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _action(rule: str, j: int, raw: Any) -> dict[str, Any]:
|
|
189
|
+
where = f"rule {rule} action {j}"
|
|
190
|
+
if not isinstance(raw, dict) or len(raw) != 1:
|
|
191
|
+
raise ConfigError(f"{where}: an action is a one-key mapping such as `reply: {{text: ...}}`")
|
|
192
|
+
kind, spec = next(iter(raw.items()))
|
|
193
|
+
if kind not in ACTIONS:
|
|
194
|
+
raise ConfigError(f"{where}: unknown action `{kind}` (known: {sorted(ACTIONS)})")
|
|
195
|
+
if kind in ("reply", "send", "forward"):
|
|
196
|
+
if not isinstance(spec, dict):
|
|
197
|
+
raise ConfigError(f"{where}: `{kind}` takes a mapping")
|
|
198
|
+
if kind in ("send", "forward"):
|
|
199
|
+
to = spec.get("to")
|
|
200
|
+
to = [to] if isinstance(to, str) else to
|
|
201
|
+
if not to or not all(isinstance(a, str) and ADDRESS.match(a) for a in to):
|
|
202
|
+
raise ConfigError(f"{where}: `{kind}.to` must be one or more literal addresses; "
|
|
203
|
+
"a recipient never comes from the message")
|
|
204
|
+
spec = {**spec, "to": list(to)}
|
|
205
|
+
if kind == "send" and not spec.get("subject"):
|
|
206
|
+
raise ConfigError(f"{where}: `send.subject` is required")
|
|
207
|
+
if kind in ("reply", "send") and ("text" in spec) == ("llm" in spec):
|
|
208
|
+
raise ConfigError(f"{where}: `{kind}` needs exactly one of `text` (a template) or `llm` (an instruction)")
|
|
209
|
+
_templates(where, spec, skip={"to"})
|
|
210
|
+
elif kind == "mcp":
|
|
211
|
+
if not isinstance(spec, dict) or not isinstance(spec.get("tool"), str) or "__" not in spec["tool"]:
|
|
212
|
+
raise ConfigError(f"{where}: `mcp.tool` is the brokered name, <server>__<tool>")
|
|
213
|
+
if PLACEHOLDER.search(spec["tool"]):
|
|
214
|
+
raise ConfigError(f"{where}: `mcp.tool` must be literal")
|
|
215
|
+
if not isinstance(spec.get("args", {}), dict):
|
|
216
|
+
raise ConfigError(f"{where}: `mcp.args` is a mapping")
|
|
217
|
+
_templates(where, spec.get("args", {}))
|
|
218
|
+
elif kind == "attention":
|
|
219
|
+
if not isinstance(spec, str) or not spec:
|
|
220
|
+
raise ConfigError(f"{where}: `attention` is the message template")
|
|
221
|
+
_templates(where, {"attention": spec})
|
|
222
|
+
elif kind == "move":
|
|
223
|
+
if not isinstance(spec, str) or not spec or PLACEHOLDER.search(spec):
|
|
224
|
+
raise ConfigError(f"{where}: `move` takes a literal folder name")
|
|
225
|
+
elif kind == "mark_read":
|
|
226
|
+
spec = True
|
|
227
|
+
return {kind: spec}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _templates(where: str, spec: Any, skip: set[str] = frozenset()) -> None:
|
|
231
|
+
if isinstance(spec, dict):
|
|
232
|
+
for k, v in spec.items():
|
|
233
|
+
if k not in skip:
|
|
234
|
+
_templates(where, v)
|
|
235
|
+
elif isinstance(spec, list):
|
|
236
|
+
for v in spec:
|
|
237
|
+
_templates(where, v)
|
|
238
|
+
elif isinstance(spec, str):
|
|
239
|
+
for name in PLACEHOLDER.findall(spec):
|
|
240
|
+
if name not in FIELDS:
|
|
241
|
+
raise ConfigError(f"{where}: unknown placeholder {{{{{name}}}}} (known: {sorted(FIELDS)})")
|
keywright_router/kw.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The router's only way out: brokered MCP calls and the LLM gateway, both through Keywright.
|
|
2
|
+
|
|
3
|
+
Calls never wait on the human (`_wait_seconds: 0`, `X-Keywright-Wait: 0`). A call that needs
|
|
4
|
+
approval comes back as Pending with the request id; the router parks the job and asks
|
|
5
|
+
`access_status` about that id on later polls instead of repeating the call, so one message
|
|
6
|
+
puts one prompt on the phone however long it takes to answer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from keywright import transport
|
|
17
|
+
from keywright.config import Profile
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KwError(Exception):
|
|
21
|
+
"""The call was refused, denied, locked or failed; retrying the same call will not help."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, kind: str, detail: dict[str, Any] | None = None):
|
|
24
|
+
self.kind = kind
|
|
25
|
+
self.detail = detail or {}
|
|
26
|
+
super().__init__(f"{kind}: {json.dumps(self.detail)[:300]}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Pending(Exception):
|
|
30
|
+
"""Waiting on the human; `request_id` is the one to poll."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, request_id: str, patterns: list[str] | None = None):
|
|
33
|
+
self.request_id = request_id
|
|
34
|
+
self.patterns = patterns or []
|
|
35
|
+
super().__init__(f"approval pending: {request_id}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Keywright:
|
|
40
|
+
url: str
|
|
41
|
+
token: str
|
|
42
|
+
session: str
|
|
43
|
+
timeout: float = 120.0
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def profile(self) -> Profile:
|
|
47
|
+
return Profile(name="router", url=self.url, token=self.token)
|
|
48
|
+
|
|
49
|
+
async def call(self, tool: str, args: dict[str, Any], reason: str) -> dict[str, Any]:
|
|
50
|
+
"""One brokered tool call. Returns the structured payload; raises Pending or KwError."""
|
|
51
|
+
result = await transport.acall(self.profile, self.session, tool,
|
|
52
|
+
{**args, "_reason": reason, "_wait_seconds": 0},
|
|
53
|
+
read_timeout=self.timeout)
|
|
54
|
+
data = transport.payload(result)
|
|
55
|
+
if getattr(result, "is_error", False):
|
|
56
|
+
kind = data.get("error") if isinstance(data.get("error"), str) else "upstream_error"
|
|
57
|
+
if kind == "approval_required" and data.get("request_id"):
|
|
58
|
+
raise Pending(data["request_id"], data.get("patterns"))
|
|
59
|
+
raise KwError(kind, data)
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
async def call_access(self, tool: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
63
|
+
"""One of Keywright's own tools (access_list_resources, access_request): the payload as it
|
|
64
|
+
comes, errors included, since their answers are states rather than failures."""
|
|
65
|
+
result = await transport.acall(self.profile, self.session, tool, args, read_timeout=30.0)
|
|
66
|
+
return transport.payload(result)
|
|
67
|
+
|
|
68
|
+
async def status(self, request_id: str) -> str:
|
|
69
|
+
"""pending | approved | denied | expired | not_found."""
|
|
70
|
+
result = await transport.acall(self.profile, self.session, "access_status", {"request_id": request_id},
|
|
71
|
+
read_timeout=30.0)
|
|
72
|
+
data = transport.payload(result)
|
|
73
|
+
return str(data.get("status") or data.get("error") or "not_found")
|
|
74
|
+
|
|
75
|
+
async def attention(self, message: str, context: str) -> None:
|
|
76
|
+
await transport.acall(self.profile, self.session, "request_attention",
|
|
77
|
+
{"message": message[:500], "context": context[:200]}, read_timeout=30.0)
|
|
78
|
+
|
|
79
|
+
async def chat(self, gateway: str, model: str, messages: list[dict[str, str]], reason: str,
|
|
80
|
+
max_tokens: int = 800) -> str:
|
|
81
|
+
headers = {"Authorization": f"Bearer {self.token}", "X-Keywright-Session": self.session,
|
|
82
|
+
"X-Keywright-Wait": "0", "X-Keywright-Reason": reason[:200]}
|
|
83
|
+
body = {"model": model, "messages": messages, "temperature": 0, "max_tokens": max_tokens}
|
|
84
|
+
async with httpx.AsyncClient(timeout=self.timeout) as http:
|
|
85
|
+
r = await http.post(f"{self.url.rstrip('/')}/llm/{gateway}/v1/chat/completions",
|
|
86
|
+
json=body, headers=headers)
|
|
87
|
+
try:
|
|
88
|
+
data = r.json()
|
|
89
|
+
except ValueError:
|
|
90
|
+
data = {}
|
|
91
|
+
if r.status_code != 200:
|
|
92
|
+
err = data.get("error") if isinstance(data.get("error"), dict) else {}
|
|
93
|
+
if err.get("code") == "approval_required" and err.get("request_id"):
|
|
94
|
+
raise Pending(err["request_id"], [f"llm/{gateway}/model/{model}"])
|
|
95
|
+
raise KwError(str(err.get("code") or f"http_{r.status_code}"), {"message": err.get("message")})
|
|
96
|
+
try:
|
|
97
|
+
return str(data["choices"][0]["message"]["content"] or "")
|
|
98
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
99
|
+
raise KwError("bad_llm_response", {"body": str(data)[:300]}) from e
|