willow-bot 0.1.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.
willow_bot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """willow-bot — GitHub App webhook + orchestrator-feeding steward."""
2
+
3
+ __version__ = "0.1.1"
willow_bot/cli.py ADDED
@@ -0,0 +1,21 @@
1
+ """Console entrypoints: willow-bot (webhook) and willow-bot-steward."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+
7
+
8
+ def webhook_main() -> None:
9
+ """Run the FastAPI webhook via uvicorn (reads BOT_HOST / BOT_PORT)."""
10
+ import uvicorn
11
+
12
+ host = os.environ.get("BOT_HOST", "127.0.0.1")
13
+ port = int(os.environ.get("BOT_PORT", "9000"))
14
+ # Root-level bot:app kept for systemd compatibility during prove.
15
+ uvicorn.run("bot:app", host=host, port=port, factory=False)
16
+
17
+
18
+ def steward_main(argv: list[str] | None = None) -> int:
19
+ from willow_bot.steward.tick import main
20
+
21
+ return main(argv if argv is not None else sys.argv[1:])
willow_bot/deposits.py ADDED
@@ -0,0 +1,137 @@
1
+ """Propose-only CI / PR deposits (§12 middle row).
2
+
3
+ Always append a local draft JSONL under ``$WILLOW_HOME/willow-bot/deposits/``.
4
+ Optionally mirror into willow-mcp ``store_put`` when MCP is configured — never seals.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ log = logging.getLogger("willow-bot.deposits")
16
+
17
+ COLLECTION = "willow_bot_ci_deposits"
18
+
19
+
20
+ def _willow_home() -> Path:
21
+ default = Path.home() / "sean-data-vault" / "willow-operator-box"
22
+ return Path(os.environ.get("WILLOW_HOME", default))
23
+
24
+
25
+ def deposits_dir() -> Path:
26
+ return _willow_home() / "willow-bot" / "deposits"
27
+
28
+
29
+ def deposits_jsonl() -> Path:
30
+ return deposits_dir() / "ci_outcomes.jsonl"
31
+
32
+
33
+ def _now() -> str:
34
+ return datetime.now(timezone.utc).isoformat()
35
+
36
+
37
+ def ci_outcome_record(
38
+ *,
39
+ repo: str,
40
+ head_sha: str | None,
41
+ check_run_id: Any,
42
+ check_name: str | None,
43
+ conclusion: str | None,
44
+ status: str | None = None,
45
+ pr_number: int | None = None,
46
+ html_url: str | None = None,
47
+ sender_type: str = "",
48
+ received_at: str | None = None,
49
+ ) -> dict[str, Any]:
50
+ """Draft claim: how CI went for repo@sha (pass and fail both recorded)."""
51
+ return {
52
+ "kind": "ci_outcome",
53
+ "lane": "draft",
54
+ "deposited_by": "willows-bot",
55
+ "actor_type": sender_type or "unknown",
56
+ "repo": repo,
57
+ "head_sha": head_sha or "",
58
+ "check_run_id": check_run_id,
59
+ "check_name": check_name or "",
60
+ "conclusion": conclusion or "",
61
+ "status": status or "",
62
+ "pr_number": pr_number,
63
+ "html_url": html_url or "",
64
+ "received_at": received_at or _now(),
65
+ "deposited_at": _now(),
66
+ }
67
+
68
+
69
+ def record_id_for(rec: dict[str, Any]) -> str:
70
+ repo = (rec.get("repo") or "unknown").replace("/", "__")
71
+ sha = (rec.get("head_sha") or "nosha")[:12]
72
+ cid = rec.get("check_run_id") or "0"
73
+ return f"ci-{repo}-{sha}-{cid}"
74
+
75
+
76
+ def append_local(rec: dict[str, Any]) -> Path:
77
+ path = deposits_jsonl()
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ with path.open("a", encoding="utf-8") as fh:
80
+ fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
81
+ return path
82
+
83
+
84
+ def deposit_ci_outcome(rec: dict[str, Any]) -> dict[str, Any]:
85
+ """Write local draft; optionally MCP store_put. Returns status receipt."""
86
+ path = append_local(rec)
87
+ out: dict[str, Any] = {
88
+ "local": str(path),
89
+ "record_id": record_id_for(rec),
90
+ "mcp": {"status": "skipped", "detail": "WILLOW_BOT_MCP not enabled"},
91
+ }
92
+ if os.environ.get("WILLOW_BOT_MCP", "").strip().lower() not in ("1", "true", "yes"):
93
+ return out
94
+ try:
95
+ from willow_bot.steward import mcp_client
96
+
97
+ app_id = os.environ.get("WILLOW_BOT_MCP_APP_ID", "willow").strip() or "willow"
98
+ result = mcp_client.call(
99
+ "store_put",
100
+ {
101
+ "app_id": app_id,
102
+ "collection": COLLECTION,
103
+ "record": rec,
104
+ "record_id": record_id_for(rec),
105
+ "deviation": 0,
106
+ },
107
+ )
108
+ out["mcp"] = {"status": "ok", "result": result}
109
+ except Exception as exc: # noqa: BLE001 — honest absence, never silent success
110
+ log.warning("mcp store_put failed: %s", exc)
111
+ out["mcp"] = {"status": "could-not-run", "detail": str(exc)}
112
+ return out
113
+
114
+
115
+ def deposit_from_check_run_payload(payload: dict) -> dict[str, Any] | None:
116
+ """Build + deposit from a completed check_run webhook payload."""
117
+ if payload.get("action") != "completed":
118
+ return None
119
+ check = payload.get("check_run") or {}
120
+ repo = (payload.get("repository") or {}).get("full_name") or ""
121
+ if not repo:
122
+ return None
123
+ prs = check.get("pull_requests") or []
124
+ pr_number = prs[0].get("number") if prs else None
125
+ sender_type = str((payload.get("sender") or {}).get("type") or "")
126
+ rec = ci_outcome_record(
127
+ repo=repo,
128
+ head_sha=check.get("head_sha"),
129
+ check_run_id=check.get("id"),
130
+ check_name=check.get("name"),
131
+ conclusion=check.get("conclusion"),
132
+ status=check.get("status"),
133
+ pr_number=pr_number,
134
+ html_url=check.get("html_url"),
135
+ sender_type=sender_type,
136
+ )
137
+ return deposit_ci_outcome(rec)
@@ -0,0 +1 @@
1
+ """Deterministic PR/CI steward (never Grove 3B Loki)."""
@@ -0,0 +1,45 @@
1
+ """Shared env for steward PR watch (deterministic gh/CI — never Grove 3B watcher)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+
7
+
8
+ def _truthy(name: str, default: str = "0") -> bool:
9
+ return os.environ.get(name, default).strip().lower() in ("1", "true", "yes")
10
+
11
+
12
+ def willow_home() -> Path:
13
+ default = Path.home() / "sean-data-vault" / "willow-operator-box"
14
+ return Path(os.environ.get("WILLOW_HOME", default))
15
+
16
+
17
+ def webhook_inbox_dir() -> Path:
18
+ return willow_home() / "upstream_steward" / "webhook_inbox"
19
+
20
+
21
+ def state_path() -> Path:
22
+ vault = os.environ.get("WILLOW_VAULT_BOX", str(willow_home()))
23
+ return Path(
24
+ os.environ.get(
25
+ "WILLOW_BOT_STEWARD_STATE",
26
+ os.environ.get("LOKI_PR_WATCH_STATE", f"{vault}/loki_pr_watch_state.json"),
27
+ )
28
+ )
29
+
30
+
31
+ def watcher_call_enabled() -> bool:
32
+ """PR watch must not call Ollama/Cerebras. Opt-in is reserved and currently rejected."""
33
+ return _truthy("WILLOW_BOT_STEWARD_CALL_WATCHER") or _truthy("LOKI_PR_WATCH_CALL_WATCHER")
34
+
35
+
36
+ def watcher_url() -> str:
37
+ """Documented for willow-bot loki.watcher only; steward does not HTTP here."""
38
+ return os.environ.get("LOKI_WATCHER_URL", "http://localhost:11434/v1").strip()
39
+
40
+
41
+ def host_sync_enabled() -> bool:
42
+ """Optional merge→pull/pip -e. Default on (legacy loki_pr_watch behaviour)."""
43
+ if "WILLOW_BOT_STEWARD_HOST_SYNC" in os.environ:
44
+ return _truthy("WILLOW_BOT_STEWARD_HOST_SYNC", "1")
45
+ return True
@@ -0,0 +1,120 @@
1
+ """Fleet scope for Loki PR watch — all operator GitHub orgs (+ optional user account)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ from functools import lru_cache
8
+ from typing import Iterator
9
+
10
+ # Used only when ``gh api /user/memberships/orgs`` is unavailable.
11
+ _DEFAULT_ORGS = (
12
+ "almanac-data",
13
+ "Die-Namic-Systems",
14
+ "forge-play",
15
+ "homestead-affairs",
16
+ "hornbook-knowledge",
17
+ "terpsi-programs",
18
+ "willow-memory",
19
+ )
20
+
21
+ DEFAULT_USER = "rudi193-cmd"
22
+
23
+
24
+ def gh_api(path: str):
25
+ return json.loads(subprocess.check_output(["gh", "api", path], text=True))
26
+
27
+
28
+ @lru_cache(maxsize=1)
29
+ def fleet_orgs() -> tuple[str, ...]:
30
+ """Org logins to scan. Override with LOKI_PR_WATCH_ORGS (comma-separated)."""
31
+ raw = os.environ.get("LOKI_PR_WATCH_ORGS", "").strip()
32
+ if raw:
33
+ return tuple(o.strip() for o in raw.split(",") if o.strip())
34
+ try:
35
+ members = gh_api("/user/memberships/orgs?per_page=100")
36
+ logins = [m["organization"]["login"] for m in members if m.get("organization")]
37
+ if logins:
38
+ return tuple(sorted(logins))
39
+ except (subprocess.CalledProcessError, KeyError, TypeError, json.JSONDecodeError):
40
+ pass
41
+ return _DEFAULT_ORGS
42
+
43
+
44
+ def fleet_user() -> str:
45
+ return os.environ.get("LOKI_PR_WATCH_USER", DEFAULT_USER).strip() or DEFAULT_USER
46
+
47
+
48
+ def repos_for_org(org: str) -> list[str]:
49
+ names: list[str] = []
50
+ page = 1
51
+ while True:
52
+ try:
53
+ batch = gh_api(f"/orgs/{org}/repos?per_page=100&page={page}")
54
+ except subprocess.CalledProcessError:
55
+ break
56
+ if not batch:
57
+ break
58
+ names.extend(r["full_name"] for r in batch)
59
+ if len(batch) < 100:
60
+ break
61
+ page += 1
62
+ return names
63
+
64
+
65
+ def repos_for_user(user: str) -> list[str]:
66
+ names: list[str] = []
67
+ page = 1
68
+ while True:
69
+ try:
70
+ batch = gh_api(
71
+ f"/users/{user}/repos?per_page=100&page={page}&type=owner"
72
+ )
73
+ except subprocess.CalledProcessError:
74
+ break
75
+ if not batch:
76
+ break
77
+ names.extend(r["full_name"] for r in batch)
78
+ if len(batch) < 100:
79
+ break
80
+ page += 1
81
+ return names
82
+
83
+
84
+ def fleet_repos() -> list[str]:
85
+ names: set[str] = set()
86
+ for org in fleet_orgs():
87
+ names.update(repos_for_org(org))
88
+ if os.environ.get("LOKI_PR_WATCH_SKIP_USER", "").strip() not in ("1", "true", "yes"):
89
+ names.update(repos_for_user(fleet_user()))
90
+ return sorted(names)
91
+
92
+
93
+ def iter_open_pulls() -> Iterator[tuple[str, int, str, str]]:
94
+ """Yield (full_name, number, title, html_url) for every open PR in fleet scope."""
95
+ for repo in fleet_repos():
96
+ try:
97
+ pulls = gh_api(f"repos/{repo}/pulls?state=open&per_page=50")
98
+ except subprocess.CalledProcessError:
99
+ continue
100
+ for p in pulls:
101
+ yield (
102
+ repo,
103
+ int(p["number"]),
104
+ str(p.get("title") or ""),
105
+ str(p.get("html_url") or ""),
106
+ )
107
+
108
+
109
+ def repo_matches_filters(repo: str, num: int, filters: list[str]) -> bool:
110
+ if not filters:
111
+ return True
112
+ key = f"{repo}#{num}"
113
+ for f in filters:
114
+ if f == key or f in key:
115
+ return True
116
+ if "/" not in f and repo.startswith(f"{f}/"):
117
+ return True
118
+ if f == repo or repo.startswith(f):
119
+ return True
120
+ return False
@@ -0,0 +1,100 @@
1
+ """Curated willow-mcp tool heartbeat for the steward (prove phase).
2
+
3
+ Calls platform verbs over MCP; does NOT own commitment dew (Kart
4
+ ``CommitmentProactiveHook`` stays until proven). Honest absence when MCP off.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from willow_bot.steward.config import willow_home
15
+
16
+
17
+ # Read-only / status verbs — no dew publish, no seal.
18
+ DEFAULT_CURATED: list[tuple[str, dict[str, Any]]] = [
19
+ ("fleet_health", {}),
20
+ ("commitment_surface", {}),
21
+ ("human_required_list", {}),
22
+ ("diagnostic_summary", {}),
23
+ ]
24
+
25
+
26
+ def _app_id() -> str:
27
+ return os.environ.get("WILLOW_BOT_MCP_APP_ID", "willow").strip() or "willow"
28
+
29
+
30
+ def _receipts_path() -> Path:
31
+ return willow_home() / "willow-bot" / "steward_heartbeat.jsonl"
32
+
33
+
34
+ def curated_calls() -> list[tuple[str, dict[str, Any]]]:
35
+ raw = os.environ.get("WILLOW_BOT_STEWARD_TOOLS", "").strip()
36
+ if not raw:
37
+ app = _app_id()
38
+ return [(name, {**args, "app_id": app}) for name, args in DEFAULT_CURATED]
39
+ # comma-separated tool names; each gets app_id only
40
+ app = _app_id()
41
+ return [(n.strip(), {"app_id": app}) for n in raw.split(",") if n.strip()]
42
+
43
+
44
+ def run_heartbeat(*, enable_mcp: bool | None = None) -> dict[str, Any]:
45
+ """Run one curated pass. Returns a receipt dict (also appended to JSONL)."""
46
+ if enable_mcp is None:
47
+ enable_mcp = os.environ.get("WILLOW_BOT_MCP", "").strip().lower() in (
48
+ "1",
49
+ "true",
50
+ "yes",
51
+ )
52
+
53
+ receipt: dict[str, Any] = {
54
+ "event": "steward_heartbeat",
55
+ "at": datetime.now(timezone.utc).isoformat(),
56
+ "tools": [],
57
+ }
58
+
59
+ if not enable_mcp:
60
+ receipt["status"] = "absent"
61
+ receipt["detail"] = "WILLOW_BOT_MCP not enabled — no tool calls"
62
+ _append(receipt)
63
+ print(json.dumps(receipt), flush=True)
64
+ return receipt
65
+
66
+ try:
67
+ from willow_bot.steward import mcp_client
68
+ except ImportError as exc:
69
+ receipt["status"] = "could-not-run"
70
+ receipt["detail"] = f"mcp package missing: {exc}"
71
+ _append(receipt)
72
+ print(json.dumps(receipt), flush=True)
73
+ return receipt
74
+
75
+ receipt["status"] = "ok"
76
+ for name, args in curated_calls():
77
+ entry: dict[str, Any] = {"tool": name, "args_keys": sorted(args)}
78
+ try:
79
+ result = mcp_client.call(name, args)
80
+ entry["outcome"] = "ok"
81
+ # Keep receipt small — summarize
82
+ if isinstance(result, dict):
83
+ entry["result_keys"] = sorted(result.keys())[:20]
84
+ else:
85
+ entry["result_preview"] = str(result)[:240]
86
+ except Exception as exc: # noqa: BLE001
87
+ entry["outcome"] = "could-not-run"
88
+ entry["detail"] = str(exc)[:400]
89
+ receipt["tools"].append(entry)
90
+
91
+ _append(receipt)
92
+ print(json.dumps(receipt), flush=True)
93
+ return receipt
94
+
95
+
96
+ def _append(receipt: dict[str, Any]) -> None:
97
+ path = _receipts_path()
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ with path.open("a", encoding="utf-8") as fh:
100
+ fh.write(json.dumps(receipt, separators=(",", ":")) + "\n")
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """Merge willow-bot fleet_bridge PR webhook inbox into loki_pr_watch state.
3
+
4
+ Reads ~/.willow/upstream_steward/webhook_inbox/*.json (WILLOW_HOME), records
5
+ consumed work_ids, emits one JSON line per newly seen pull_request event.
6
+
7
+ PR watch never calls LOKI_WATCHER_URL / Ollama; Grove Loki (3B) is separate.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from willow_bot.steward.config import (
16
+ watcher_call_enabled,
17
+ watcher_url,
18
+ webhook_inbox_dir,
19
+ )
20
+
21
+ _MAX_CONSUMED = 5000
22
+ _MAX_SIGNALS = 100
23
+
24
+
25
+ def _load_state(path: Path) -> dict:
26
+ if path.is_file() and path.read_text().strip():
27
+ return json.loads(path.read_text())
28
+ return {"seen": [], "open": [], "merged_synced": []}
29
+
30
+
31
+ def _emit(obj: dict) -> None:
32
+ print(json.dumps(obj), flush=True)
33
+
34
+
35
+ def _repo_pr(item: dict) -> str:
36
+ repo = item.get("repo") or ""
37
+ num = item.get("number")
38
+ if repo and num is not None:
39
+ return f"{repo}#{num}"
40
+ return ""
41
+
42
+
43
+ def ingest(state_path: Path) -> int:
44
+ if watcher_call_enabled():
45
+ _emit(
46
+ {
47
+ "event": "error",
48
+ "detail": (
49
+ "WILLOW_BOT_STEWARD_CALL_WATCHER / LOKI_PR_WATCH_CALL_WATCHER is set but not "
50
+ "supported; willow-bot runs loki.watcher separately "
51
+ f"(LOKI_WATCHER_URL={watcher_url()!r} is ignored here)."
52
+ ),
53
+ }
54
+ )
55
+ return 1
56
+
57
+ inbox = webhook_inbox_dir()
58
+ state = _load_state(state_path)
59
+ state.setdefault("seen", [])
60
+ state.setdefault("open", [])
61
+ state.setdefault("merged_synced", [])
62
+ consumed: set[str] = set(state.get("inbox_consumed") or [])
63
+ signals: list[dict] = list(state.get("webhook_signals") or [])
64
+
65
+ if not inbox.is_dir():
66
+ state_path.parent.mkdir(parents=True, exist_ok=True)
67
+ state["inbox_consumed"] = sorted(consumed)
68
+ state["webhook_signals"] = signals[-_MAX_SIGNALS:]
69
+ state_path.write_text(json.dumps(state, indent=2) + "\n")
70
+ return 0
71
+
72
+ for path in sorted(inbox.glob("*.json")):
73
+ try:
74
+ item = json.loads(path.read_text(encoding="utf-8"))
75
+ except (json.JSONDecodeError, OSError):
76
+ continue
77
+ wid = item.get("work_id")
78
+ if not wid or wid in consumed:
79
+ continue
80
+ if item.get("kind") != "pull_request":
81
+ continue
82
+
83
+ consumed.add(wid)
84
+ key = _repo_pr(item)
85
+ if not key:
86
+ continue
87
+
88
+ ev = {
89
+ "event": "webhook_pr",
90
+ "repo_pr": key,
91
+ "action": item.get("action"),
92
+ "title": item.get("title") or "",
93
+ "url": item.get("html_url") or "",
94
+ "received_at": item.get("received_at"),
95
+ "work_id": wid,
96
+ "merged": item.get("merged"),
97
+ "pr_state": item.get("state"),
98
+ "source": item.get("source") or "willow-bot",
99
+ }
100
+ _emit(ev)
101
+ signals.append({k: v for k, v in ev.items() if k != "event"})
102
+
103
+ if len(consumed) > _MAX_CONSUMED:
104
+ consumed = set(sorted(consumed)[-_MAX_CONSUMED:])
105
+
106
+ state["inbox_consumed"] = sorted(consumed)
107
+ state["webhook_signals"] = signals[-_MAX_SIGNALS:]
108
+ state_path.parent.mkdir(parents=True, exist_ok=True)
109
+ state_path.write_text(json.dumps(state, indent=2) + "\n")
110
+ return 0
111
+
112
+
113
+ def main() -> int:
114
+ if len(sys.argv) != 2:
115
+ print("usage: willow-bot-steward inbox <state.json>", file=sys.stderr)
116
+ return 2
117
+ return ingest(Path(sys.argv[1]))
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())
@@ -0,0 +1,118 @@
1
+ """Thin MCP stdio client for steward prove-phase tool calls.
2
+
3
+ Re-points the lifecycle pattern from ratatosk.mcp_client (Rule 11) — same env
4
+ forwarding lesson, no dependency on the ratatosk package.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import shlex
12
+ import sys
13
+ import threading
14
+ from typing import Any
15
+
16
+ _mcp_session = None
17
+ _mcp_loop: asyncio.AbstractEventLoop | None = None
18
+ _mcp_stop_event: asyncio.Event | None = None
19
+ _mcp_thread: threading.Thread | None = None
20
+ _mcp_argv: list[str] | None = None
21
+
22
+ _ENV_PREFIXES = ("WILLOW_", "WILLOW_BOT_", "PG")
23
+
24
+
25
+ def default_mcp_argv() -> list[str]:
26
+ override = os.environ.get("WILLOW_BOT_MCP_COMMAND", "").strip()
27
+ if override:
28
+ return shlex.split(override)
29
+ module = os.environ.get("WILLOW_BOT_MCP_MODULE", "willow_mcp")
30
+ return [sys.executable, "-m", module]
31
+
32
+
33
+ def server_env() -> dict[str, str]:
34
+ from mcp.client.stdio import get_default_environment
35
+
36
+ if os.environ.get("WILLOW_BOT_MCP_INHERIT_ENV", "").strip().lower() in (
37
+ "1",
38
+ "true",
39
+ "yes",
40
+ ):
41
+ return dict(os.environ)
42
+ env = dict(get_default_environment())
43
+ env.update({k: v for k, v in os.environ.items() if k.startswith(_ENV_PREFIXES)})
44
+ return env
45
+
46
+
47
+ def _call_sync(coro):
48
+ assert _mcp_loop is not None
49
+ return asyncio.run_coroutine_threadsafe(coro, _mcp_loop).result(timeout=90)
50
+
51
+
52
+ async def _lifecycle(argv: list[str], ready: threading.Event) -> None:
53
+ global _mcp_session, _mcp_stop_event
54
+ from mcp import ClientSession
55
+ from mcp.client.stdio import StdioServerParameters, stdio_client
56
+
57
+ stop = asyncio.Event()
58
+ _mcp_stop_event = stop
59
+ params = StdioServerParameters(command=argv[0], args=argv[1:], env=server_env())
60
+ async with stdio_client(params) as (read, write):
61
+ async with ClientSession(read, write) as session:
62
+ await session.initialize()
63
+ _mcp_session = session
64
+ ready.set()
65
+ await stop.wait()
66
+
67
+
68
+ def start(argv: list[str] | None = None) -> None:
69
+ global _mcp_loop, _mcp_thread, _mcp_argv
70
+ if _mcp_session is not None:
71
+ return
72
+ argv = argv or default_mcp_argv()
73
+ _mcp_argv = argv
74
+ loop = asyncio.new_event_loop()
75
+ _mcp_loop = loop
76
+ ready = threading.Event()
77
+ _mcp_thread = threading.Thread(
78
+ target=lambda: loop.run_until_complete(_lifecycle(argv, ready)),
79
+ daemon=True,
80
+ name="willow-bot-mcp",
81
+ )
82
+ _mcp_thread.start()
83
+ if not ready.wait(timeout=90):
84
+ raise RuntimeError("MCP server did not initialize within 90s")
85
+
86
+
87
+ def shutdown() -> None:
88
+ global _mcp_session, _mcp_loop, _mcp_thread, _mcp_stop_event
89
+ if _mcp_stop_event is not None and _mcp_loop is not None:
90
+ _mcp_loop.call_soon_threadsafe(_mcp_stop_event.set)
91
+ if _mcp_thread is not None:
92
+ _mcp_thread.join(timeout=10)
93
+ _mcp_session = None
94
+ _mcp_loop = None
95
+ _mcp_thread = None
96
+ _mcp_stop_event = None
97
+
98
+
99
+ def call(name: str, inputs: dict[str, Any]) -> Any:
100
+ start()
101
+ assert _mcp_session is not None
102
+ result = _call_sync(_mcp_session.call_tool(name, inputs))
103
+ # SDK 1.x isError / 2.x is_error
104
+ is_err = getattr(result, "isError", None)
105
+ if is_err is None:
106
+ is_err = getattr(result, "is_error", False)
107
+ texts = []
108
+ for block in getattr(result, "content", None) or []:
109
+ t = getattr(block, "text", None)
110
+ if t is not None:
111
+ texts.append(t)
112
+ payload = "\n".join(texts) if texts else str(result)
113
+ if is_err:
114
+ raise RuntimeError(payload)
115
+ try:
116
+ return json.loads(payload)
117
+ except (json.JSONDecodeError, TypeError):
118
+ return payload