Bot fleet (devplacepy/services/bot/)
This file documents the Playwright-driven AI persona fleet. Claude Code auto-loads it when a file under devplacepy/services/bot/ is read or edited.
Overview
BotsService (services/bot/) is a Playwright-driven fleet of AI personas that browse and interact with a DevPlace instance (posts, comments, votes, gists, projects, issues, follows, messages). It is the former standalone dpbot.py refactored into a package and wired into the service framework. Opt-in (default_enabled = False). Every other AI consumer's endpoint defaults reference "news, bots, Devii guests" collectively as internal-gateway consumers (see GatewayService).
Module layout (one responsibility per file)
| File | Purpose |
|---|---|
config.py |
Static constants + defaults (URLs, model, costs, personas, categories, paths) |
runtime.py |
BotRuntimeConfig dataclass - per-run settings threaded into a bot |
state.py |
BotState dataclass + JSON persistence (incl. the per-bot identity card) |
llm.py |
LLMClient (parameterized: key/url/model/costs); content generation + quality checks + the decide/generate_identity decision engine |
news_fetcher.py |
NewsFetcher - TTL-cached article source |
browser.py |
BotBrowser - Playwright wrapper (human-like typing/clicking/scrolling) |
registry.py |
ArticleRegistry - flock-based cross-bot article dedupe |
bot.py |
DevPlaceBot - the orchestrator (sessions, action cycle, run_forever) |
service.py |
BotsService(BaseService) - fleet manager + metrics |
Mechanics
run_onceis a reconcile tick: it ensuresbot_fleet_sizebot tasks are alive (relaunching dead ones), scales the fleet up/down, and is a no-op heavy work otherwise. Each bot is anasynciotask runningDevPlaceBot.run_foreverin the lock worker's loop.on_disablecancels the fleet (each bot closes its browser onCancelledError).- All "startup parameters" of the old script are
config_fields:bot_fleet_size(was--bots),bot_headless(was--headed),bot_max_actions(was--actions), plusbot_base_url,bot_api_url(defaults to the internal gateway),bot_news_api,bot_model(defaults tomolodetz),bot_api_key(secret; falls back tointernal_gateway_key()),bot_input_cost_per_1m,bot_output_cost_per_1m. - A bot uses its own account's
api_keyfor gateway calls once it has an account.bot_api_key(->cfg.api_key, the sharedinternal_gateway_key()fallback) is only the bootstrap credential used until the bot registers/logs in. After_ensure_auth()succeeds inrun_forever,DevPlaceBot._adopt_account_api_key()fetches the bot's ownusers.api_keyfrom its profile JSON (fetch('/profile/{username}', {Accept: 'application/json'})over the authenticated browser session, whereProfileOut.api_keyis exposed to the owner) and switchesLLMClient.api_keyto it, persisting it inBotState.account_api_key. The key is read once and reused on every later session (theLLMClientis constructed withstate.account_api_key or cfg.api_key, and_raw_callreadsself.api_keyper request so a mid-session swap takes effect on the next call). This routes each bot's gateway spend through its own user attribution (resolve_owner()->(owner_kind="user", owner_id=uid)) instead of the shared internal key. Adoption is best-effort: a fetch failure leaves the bot on the fallback key. - Behavior and pacing tuning are admin
config_fieldstoo, threaded throughBotRuntimeConfiginto the bot (never read module constants directly for these). Theconfig.pyconstants (MAX_BOTS_PER_ARTICLE,ARTICLE_TTL_DAYS,GIST_MIN_LINES,ACTION_PAUSE_MIN_SECONDS/ACTION_PAUSE_MAX_SECONDS,BREAK_SCALE_DEFAULT) are only the defaults; the live values come from the settings: Behavior group -bot_max_per_article(->ArticleRegistry.max_per_article),bot_article_ttl_days(->ArticleRegistry.ttl_days),bot_gist_min_lines(->LLMClient.gist_min_lines); Pacing group -bot_action_pause_min_seconds/bot_action_pause_max_seconds(the intra-session pause, clamped lo/hi inDevPlaceBot.__init__so an inverted pair is harmless) andbot_break_scale(a float multiplier on the between-session break, floored at 0.1 and the result floored at 5s); Decisions group -bot_ai_decisions(->BotRuntimeConfig.ai_decisions, the AI-decision kill switch) andbot_decision_temperature(->BotRuntimeConfig.decision_temperature). To add a new tuning knob: add the default toconfig.py, aConfigFieldtoBotsService.config_fields(itsgroupcreates/joins a UI section automatically), a field toBotRuntimeConfig, thecfg[...]mapping in_launch_slot, and consume it via the runtime config - do not reach for the module constant at the call site. - Live pricing is published via
collect_metrics(): fleet stat cards (bots running, fleet cost, LLM calls, tokens, posts/comments/votes) plus a per-bot table.Fleet costis cumulative across restarts because each bot seeds itsLLMClienttotals from its savedBotState. Cost is read from the gateway's authoritative per-call headers, not recomputed locally.LLMClient._account_usage(resp.headers, body_usage)parses theX-Gateway-*response headers viaopenai_gateway.usage.parse_usage_headers(the shared parser, also used byservices/correction.py) and takesX-Gateway-Cost-USDplus the prompt/completion token counts straight from the gateway, so the figure is cache-aware and native-cost-aware (it matches what/admin/ai-usageattributes to each bot's account). Thebot_input_cost_per_1m/bot_output_cost_per_1msettings are fallback-only, applied to the response-body token counts solely when the endpoint returns no gateway headers (a non-gateway OpenAI URL); their defaults (0.14/0.28) mirror the gateway's chat pricing so even the fallback is sane. (Before this, the bots flatly multiplied every prompt token by an inflated0.27/1.10with no cache awareness, which massively overstated cost and the 24h projection.)Cost rateandProjected 24h costcome from a sliding window, not lifetime:_sample_cost()appends(now, total_cost)each tick into_cost_samples, evicts samples older thanconfig.COST_WINDOW_SECONDS(600s), and measures the cost delta over the retained span; the projection is shown only once the window reachesconfig.COST_WARMUP_SECONDS(120s), otherwise both cards read "warming up". Samples reset when_started_atchanges (_cost_anchor). Never project from lifetimeFleet cost/uptime, and never anchor the rate at startup - the boot burst (all bots ramping at once) then dominates the average and massively overstates a 24h projection extrapolated 274x from a few minutes. - Heavy deps (
playwright,faker) are lazily imported insiderun_once/_launch_slot(install thebotsextra). The service registers and shows in the admin tab even when they are absent; it logs and stays idle. Per-bot state lives in~/.devplace_bots/state_slot{N}.json; the article registry in~/.dpbot_article_registry.json. - Direct messages stay a dialog, never a monologue. A bot may open a conversation, but
_compose_messagefirst calls_spoke_last_in_thread()and refuses to send when the last.message-bubblein.messages-threadis.mine(the bot itself sent the most recent message). So a bot only replies after the other person has spoken since its last message; it never sends two messages in a row. An empty thread (no bubbles) is allowed, so a bot can still initiate contact. This guards both reply paths (_check_messagesand_send_message), mirroring how a real person waits for a reply before writing again.
Live screenshot monitor (services/bot/monitor.py, /admin/bots)
The admin Bot Monitor (/admin/bots, sidebar link in admin_base.html, require_admin, noindex) shows the latest low-quality screenshot per bot with its username, persona, and current action/status, auto-refreshing every 2s. It is the constructive counterpart to the cost metrics: cost tells you what the fleet spent, the monitor tells you what each bot is looking at right now.
- Capture is a
BotBrowserconcern because the browser owns the Playwrightpage.BotBrowser.bind_monitor(slot, username, persona)(called fromDevPlaceBot.run_foreveronce auth completes),BotBrowser.note(action=, status=), andBotBrowser.capture(reason, force=)are the surface.capturetakes a JPEG viapage.screenshot(type="jpeg", quality=MONITOR_JPEG_QUALITY=35, scale="css"), throttled to one everyMONITOR_MIN_INTERVAL_SECONDS(1s) unlessforce=True. Capture is best-effort and never raises into the bot loop. It fires at the meaningful-event choke points already centralized inBotBrowser:goto(page load, forced),scroll,fill(field input),reload(forced), plusDevPlaceBot._action(tag, detail)(every post/comment/vote/react/gist - an action-labelled forced capture viaasyncio.create_task). - Storage is in-memory + one file per bot, never accumulating.
monitor(theBotMonitorsingleton) holds aslot -> BotFramedataclass map of tiny metadata, and writes the JPEG bytes toBOT_DIR/monitor/slot{N}.jpg(config.DATA_DIR, OUTSIDE the package, NOT under/static), overwritten atomically in place (.tmpthenreplace). One frame per slot, capped at the fleet size (bot_fleet_size, 0 to 20)._stop_slotcallsmonitor.drop(slot)to evict the row + unlink the file. No DB table, no soft-delete (nothing is persisted as a row), no shard tree (one file per bot is already bounded). This is what keeps it cheap at full fleet: at most 20 small JPEGs in RAM-backed files, refreshed in place. - Transport is polling, not WebSocket - deliberately. Frames live only in the worker running the Bots service (the service lock owner), exactly like container logs/metrics. Rather than a lock-owner-gated WS with the 4013 retry, the monitor reuses the existing
service_statecross-worker bridge: the lock owner publishes the frame metadata incollect_metrics()["frames"](persisted to theservice_stateDB row byBaseService._persist_state), and ANY worker reads it back viaservice_manager.get_service("bots").describe()["metrics"]["frames"]. The page pollsGET /admin/bots/data(Poller, 2s,pauseHidden) for that metadata and renders<img>tags atGET /admin/bots/{slot}/frame.jpg(raw bytes from the sharedBOT_DIR/monitor/file,Cache-Control: no-store, served by any worker since it is a file underDATA_DIR). Theframe_urlcarries a?t=captured_atcache-buster so a new frame is fetched only when it changed. This is the lightest correct option for the multi-worker model: no extra socket, no handshake, no 4013 bounce, and it reuses the same DB-backed metrics path the services page already polls. JSON shape isschemas.AdminBotsOut/BotFrameOut; Devii reads the same/admin/bots/datavia the adminbot_monitoraction; docs indocs_api.py(admin-bots-*, the frame endpoint inNON_BODY_ENDPOINTS). - Frontend is
static/js/BotMonitor.js(app.botMonitor, one class,Http+Poller) rendering thestatic/css/bots.cssresponsive grid (bot-active/bot-idleby frame age, design tokens only). Add a new capture point by callingself.b.capture("reason")(ornote(...)then capture) at the new event; do not screenshot outsideBotBrowser(it owns the page and the throttle).
Realism: titles, topic spread, gist quality, inter-bot threads, thread-aware distinct opinions
Five content/behaviour mechanics keep the fleet from reading as machine-generated. Treat them as the realism contract; do not regress them.
- Post titles are rewritten, never the raw headline. A post body reacts to a news article, but the title is generated separately by
llm.generate_post_title(headline, persona, category)- a short (3 to 8 word) human title in the persona's voice, not the verbatim article headline. The raw headline is still the registry dedupe key (clean_title), but the typed title istype_title(the rewritten one,strip_md-capped at 120). Same applies to projects:generate_project_title/generate_project_desctake the persona and run throughstrip_label.LLMClient.strip_labelremoves LLM label echoes (Title:,Project Name:,Snippet name:, and a trailingConcept:/Description:clause) so a title can never beProject Name: X Concept: Y. Run every generated title throughstrip_label. - Personality drives topic and category, not just voice. Category is chosen by
config.pick_category(persona)(a persona-weightedrandom.choicesoverPERSONA_CATEGORY_WEIGHTS), replacing the old content classifier - a grumpy_senior rants, an enthusiastic_junior shows off and asks questions. Article selection is persona-scored:config.persona_article_score(article, persona)countsSEARCH_TERMSkeyword hits, and both_pick_article(cache path) andArticleRegistry.reserve_unused(direct path) rank articles by score plus arandom()jitter, so different personas gravitate to different news rather than all reacting to the same headline. - Gists are quality-gated and non-trivial.
generate_gistnow writes the code first (8 to 20 lines, persona-flavoured viaPERSONA_GIST_FLAVOR, language drawn fromPERSONA_LANGUAGES), then titles/describes it from the code._create_gistruns a two-attempt loop throughllm.gist_quality_check(title, code, language): aTRIVIAL_GIST_TERMSblocklist on the title, a minimum non-empty line count (LLMClient.gist_min_lines, adminbot_gist_min_lines, default 6), and a strict LLM judge that rejects 101-tutorial snippets. Mirrors the post/commentquality_checkloop; a reject regenerates once then skips. - Every comment is thread-aware and must hold a distinct opinion. A bot never comments blind to what others already said. Before generating,
_comment_on_post/_reply_to_random_commentcallDevPlaceBot._existing_comments(), which scrapes the lastMAX_SIBLING_COMMENTS(8) visible.comment-textbodies with their/profile/author (own comments excluded, each capped atSIBLING_SNIPPET_LEN=240). The block is threaded intollm.generate_comment(..., existing_comments=), whose system prompt then forces a point none of them made - a different angle, a missed caveat, or respectful disagreement with one of them by name - and forbids repeating any opinion, framing, example, or question already raised. The mechanical backstop is inllm.quality_check(..., siblings=): a candidate whose_overlap_ratioagainst the joined sibling text exceedsSIBLING_OVERLAP_THRESHOLD(0.55) is rejected asechoes another commentand regenerated (same two-attempt loop as the source-restatement gate atRESTATEMENT_OVERLAP_THRESHOLD=0.6). This is what stops a salient post from drawing a dozen near-identical "tipping point / feedback loop / cooldown" replies. Any new comment-generation path MUST gather siblings and pass them to bothgenerate_commentandquality_check. - Usernames read like nerd handles, not real names. Bots do NOT sign up with faker person names; they pick devRant/Hacker-News-style handles. Two layers, LLM-first with an offline fallback: (1)
llm.generate_handle_candidates(persona)asks the model for ~8 distinct handles grounded in the persona and itsSEARCH_TERMSinterests (tech nouns, leetspeak, adjective+noun, creatures, short word+number), each run throughhandles.sanitize_handle; the pool is cached onBotState.handle_candidatesand consumed by_next_handle(). (2)handles.make_handle(interests)is the pure, offline algorithmic generator (curated word banks + probabilistic leetspeak + number/separator/casing decoration, persona-seeded fromSEARCH_TERMS), used as the fallback whenever the LLM pool is empty or a signup collides. Both layers emit only[A-Za-z0-9_-], 3 to 20 chars (the oldfirst.laststyles silently failed signup validation, which rejects dots). On a third signup retry a random number is appended to bust collisions. Word banks and tuning constants live inservices/bot/handles.py; never reintroduce faker person-name handles. - Bots engage each other, and threads deepen.
ArticleRegistryallows up tomax_per_articleholders per article (adminbot_max_per_article, default 2), each with a distinct category (stored asholders: [{bot, category, time}]; old{bot, time}rows are normalized on load), so two bots can post different angles on the same trending news - which gives them each other's posts to discuss. A holder ages out afterttl_days(adminbot_article_ttl_days, default 7)._engage_community()(called once per normal/deep session after notifications) goes to/feed?tab=recent|trending, ranks non-own posts with a preference forknown_usersauthors, opens one, comments, and replies into the comment thread. The on-post reply-to-comment probability is also raised so multi-bot threads actually form.reserve(title, owner, category)is idempotent per owner and enforces the same distinct-angle / max-holders rules asreserve_unused.
Profile disclosure signal (soft bot marker)
Every bot's profile must carry a vague, never-literal hint that the account is synthetic, anchored on config.HOME_URL (https://devplace.net). The deterministic marker is the host devplace.net appearing in the bio; the profile website field is set to HOME_URL (replacing the old fake https://{slug}.dev). Three layers, all funneled through the single _update_profile choke point (so the procedural path and the AI-decision update_profile action both comply):
- Generation:
llm.generate_bioasks for a subtle, playful not-flesh-and-blood hint namingHOME_URLas home base, explicitly forbidding the words bot/AI/artificial/automated/machine.generate_profile_fieldsreturnsHOME_URLas the website. - Deterministic backstop:
_update_profileruns the generated bio throughconfig.ensure_bio_signal(bio[:400])- if the model omitted the marker, a random phrase fromconfig.BIO_SIGNAL_PHRASES(each containingHOME_URL) is appended, so a saved bio always passesconfig.bio_has_signal. - Boot enforcement (old and new bots):
run_forevergates on the per-processself._profile_verifiedflag: at the first session of every boot,_ensure_profile_signal()fetches the bot's own profile JSON via_fetch_own_profile()(the generalized self-profile fetch that_fetch_account_api_keyalso uses) and checks_profile_has_signal()(bio marker present AND website ==HOME_URL). A pre-existing profile lacking the marker is rewritten via_update_profile; a failed refresh leaves the flag unset so the next session retries. Never bypass_update_profilewhen writing bot profile fields - it is the enforcement point.
AI-driven decisions and identity cards (bot_ai_decisions)
Opt-in mechanic (off by default; full design and cost model in aibots.md). When bot_ai_decisions is on, a bot replaces the procedural action cascade with one LLM decision call per page, driven by a unique AI-generated identity. Do not regress the grounding and the kill-switch fallback.
- Two LLM entrypoints, both on
LLMClient.generate_identity(archetype)runs once per bot (seeded from the persona archetype) and returns the identity card:name,backstory,interests,dislikes,temperament, and four 0.0-1.0 scalars (verbosity,contrarianness,generosity,curiosity) plusrhythm._normalize_identityclamps every scalar and defaultsintereststo the archetype'sSEARCH_TERMS, so a malformed model reply can never produce a broken card. The card persists onBotState.identity(serialised tostate_slot{N}.json); old state files load with an empty dict and regenerate on first AI session.decide(identity, page_state, menu, history, temperature)returns{plan, energy, stop_after}(strict JSON), the per-page ordered action plan. - JSON is parsed raw, never
clean()-ed._callruns the output throughclean()(which strips*/_and collapses whitespace, corrupting JSON keys likestop_after); the decision engine instead uses_raw_call(the extracted HTTP+cost-accounting core, noclean) and_parse_json(tolerates code fences and prose wrappers by slicing the first{..}). When adding any JSON-returning LLM method, use_raw_call, not_call. - The harness supplies the options; the model only picks.
DevPlaceBot._build_menu(page_state)builds the action menu from the same observable-page predicates the random_cycleuses, listing only actions that are physically possible and policy-allowed (throttles likecan_post/can_project/own-post-no-mention are enforced by omitting the action).decidefilters the returned plan against that menu (_filter_plandrops any unknown action), re-asks once on malformed/empty output, and the executor_run_planfalls back to a singlenavigateto feed when the plan is still empty - never arandomdraw._dispatch_actionmaps each action string to the existing per-action method (_comment_on_post,_vote_for_page,_react_to_object,_navigate_to, ...); content actions still run theirquality_check/gist_quality_checkgates unchanged, so quality cannot degrade. _cycle_aimirrors_cycle's signature and return tuple sorun_foreverswaps between them with(self._cycle_ai if use_ai else self._cycle)(...).energyandstop_afterreplace_session_typemood and_fatigue_check: the session ends whensession_actions >= stop_after, the intra-action pause is_scaled_pause()(scaled byenergy), and the between-session break is scaled byenergytoo. Timing jitter (_read_like_human,_type_like_human,_idle) stays procedural - the model decides what, not the millisecond cadence.use_aiisself._ai_decisions and bool(self.state.identity); identity generation and the flag are gated so a disabled fleet pays nothing and behaves exactly as before.