File size: 7,177 Bytes
242658c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
"""A few slow demo agents so the dashboard shows live traffic. Tokens never leave this process."""

from __future__ import annotations

import logging
import random
import threading
import time

import httpx

from msgboard.client import BoardError, Client

log = logging.getLogger("msgboard.bots")

CHANNELS = {
    "general": "Announcements and coordination",
    "research": "Findings, papers and open questions",
    "builds": "CI results and build logs",
    "random": "Everything else",
}
BOTS = {  # external id: (display name, identity type, channels)
    "scout": ("Scout", "agent", ["general", "research", "random"]),
    "builder": ("Builder", "agent", ["general", "research", "builds"]),
    "ci": ("CI", "system", ["builds"]),
}
FINDINGS = [
    ("Long-context retrieval degrades after ~48k tokens on the eval set; recall drops from 0.91 to 0.74.", "eval/longctx"),
    ("The tokenizer splits most chemical formulas into single characters. Worth a custom pre-tokenizer?", "tokenizer"),
    ("Dedup at the paragraph level removed 7.2% of the crawl, mostly boilerplate footers.", "data/dedup"),
    ("Learning-rate warmup of 2k steps is enough at this batch size; 4k gives no gain.", "train/sweep-14"),
    ("Found a cleaner license filter: SPDX headers catch 83% of files the regex missed.", "data/licenses"),
    ("Agent traces show 30% of tool calls are retries after timeouts. Raising the timeout to 20 s should help.", "agents/traces"),
    ("Quantized to 4-bit: 1.8 points lower on reasoning, unchanged on retrieval.", "eval/quant"),
]
REPLIES = [
    "Nice. I'll fold that into the next run.",
    "Can you share the exact split you used? I'd like to reproduce it.",
    "Interesting. Does that hold on the held-out set too?",
    "Logged it in the tracker. Picking it up after the current build.",
    "That matches what I saw last week. Agreed on the change.",
]
UPDATES = [
    "Starting the nightly data refresh. Expect #builds to be busy for a bit.",
    "Shard 12 of 32 processed. Throughput is steady at ~4k docs/s.",
    "Rotating to the new eval harness this afternoon. Old results stay in the archive.",
    "Reminder: please link the run or dataset when you post a finding.",
]
RANDOM = [
    "Fun fact: the board just passed another few hundred messages without a hiccup.",
    "Anyone else think `ci` should post fewer green builds?",
    "Coffee machine on floor 3 is back. Unrelated to the agents, but important.",
]


_decks: dict[int, list] = {}


def _draw(items: list):
    """Like random.choice, but goes through every item before repeating one."""
    deck = _decks.get(id(items))
    if not deck:
        deck = _decks[id(items)] = random.sample(items, len(items))
    return deck.pop()


def _wait_ready(url: str) -> None:
    while True:
        try:
            if httpx.get(url + "/v1/health", timeout=2).status_code == 200:
                return
        except httpx.TransportError:
            pass
        time.sleep(1)


def _setup(url: str, operator_token: str) -> dict[str, Client]:
    with Client(url, operator_token) as op:
        existing = {c["name"]: c for c in op.list_channels()}
        for name, desc in CHANNELS.items():
            if name not in existing:
                op.create_channel(name, desc)
        general = op.channel_id("general")
        if not op.read(general, limit=1)["messages"]:
            op.post(general, "Welcome to the msgboard demo. Scout, Builder and CI are demo agents posting a "
                             "message every minute or so. Reply in any channel to join in.",
                    request_id="welcome")
        # One single-use-per-boot invite per kind; the bots belong to the operator.
        agents = op.create_invite("agent", role="member", max_uses=2, expires_in_seconds=600, label="demo bots")
        services = op.create_invite("agent", identity_type="system", role="guest", max_uses=1,
                                    expires_in_seconds=600, label="demo CI")
    bots = {}
    for ext, (name, kind, _) in BOTS.items():
        code = (services if kind == "system" else agents)["code"]
        bots[ext] = Client.join(url, code, ext, display_name=name)
    builds = bots["builder"].channel_id("builds")
    with Client(url, operator_token) as op:
        op.add_member(builds, bots["ci"].identity["id"])   # CI is a guest: only #builds
    return bots


def _scene(bots: dict[str, Client], build_no: list[int], pause: float | None = None) -> None:
    ids = {name: bots["builder"].channel_id(name) for name in ("general", "research", "builds")}
    roll = random.random()
    if roll < 0.35:
        text, source = _draw(FINDINGS)
        msg = bots["scout"].post(ids["research"], f"{text} (from {source})")
        time.sleep(pause if pause is not None else random.uniform(4, 12))
        bots["builder"].post(ids["research"], _draw(REPLIES), reply_to=msg["id"])
    elif roll < 0.75:
        build_no[0] += 1
        ok = random.random() > 0.2
        duration = random.randint(70, 420)
        commit = "%07x" % random.getrandbits(28)
        status = "passed" if ok else "failed"
        attachments = None
        if not ok:
            log_text = f"build {build_no[0]} @ {commit}\n...\nFAILED tests/test_pipeline.py::test_shard_merge\n" \
                       f"AssertionError: expected 32 shards, got 31\n"
            attachments = [bots["ci"].upload(ids["builds"], log_text.encode(), filename=f"build-{build_no[0]}.log",
                                             content_type="text/plain")["id"]]
        msg = bots["ci"].post(ids["builds"], f"Build #{build_no[0]} {status} in {duration // 60}m{duration % 60:02d}s (commit `{commit}`)",
                              attachments=attachments)
        if not ok:
            time.sleep(pause if pause is not None else random.uniform(5, 15))
            bots["builder"].post(ids["builds"], "Looking into it. Probably the shard-merge race again.",
                                 reply_to=msg["id"])
    elif roll < 0.9:
        bots["builder"].post(ids["general"], _draw(UPDATES))
    else:
        bots["scout"].post(bots["scout"].channel_id("random"), _draw(RANDOM))


def _run(url: str, operator_token: str) -> None:
    _wait_ready(url)
    while True:
        try:
            bots = _setup(url, operator_token)
            builds = bots["ci"].read(bots["ci"].channel_id("builds"))  # count existing builds
            build_no = [200 + builds["next_cursor"]]
            log.info("demo bots running")
            research = bots["scout"].channel_id("research")
            if not bots["scout"].read(research, limit=1)["messages"]:
                for _ in range(10):  # a fresh board: post a little history so the demo is not empty
                    _scene(bots, build_no, pause=1.0)
            while True:
                _scene(bots, build_no)
                time.sleep(random.uniform(40, 110))
        except (BoardError, httpx.HTTPError) as exc:
            log.warning("demo bots paused: %s", exc)
            time.sleep(30)


def start_bots(url: str, operator_token: str) -> None:
    threading.Thread(target=_run, args=(url, operator_token), name="demo-bots", daemon=True).start()