summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2026-06-04 14:33:17 -0400
committerBen Sima <ben@bensima.com>2026-06-04 14:33:17 -0400
commit2792e1e667ba25ea37890b1d51f1f25525e331a2 (patch)
tree4b562592ae0da9f70db6e46c5d9dd8bc4653744f
parent12dd309b8e334eaf19eae6d9eb9bb85024457b8a (diff)
Replace agentd with one-shot agent
-rw-r--r--README.md6
-rwxr-xr-xdeploy.sh47
-rw-r--r--flake.lock21
-rw-r--r--flake.nix31
-rw-r--r--main.py157
5 files changed, 172 insertions, 90 deletions
diff --git a/README.md b/README.md
index 0ffb3a1..273fb83 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
# slopbot
-Zulip bot for [meshheads.org](https://zulip.meshheads.org) that replies with stock prices when it sees `$TICKER` patterns in messages.
+Zulip bot for [meshheads.org](https://zulip.meshheads.org) that replies with stock prices when it sees `$TICKER` patterns and calls `agent` for `@slop` mentions.
## Usage
-Post `$NVDA` or `$AAPL is mooning` in any stream and slopbot replies with the current price.
+Post `$NVDA` or `$AAPL is mooning` in any stream and slopbot replies with the current price. Mention `@slop` and it runs a one-shot `agent` subprocess with recent Zulip topic history.
## Setup
@@ -21,8 +21,10 @@ Get your `.zuliprc` from Zulip: Settings → Bots → your bot → download conf
- `main.py` — single-file bot, raw `zulip` event loop (no framework)
- `fetch_price(ticker)` — isolated data layer, easy to swap yfinance for a real API
+- `ask_agent(msg, content)` — runs the Rust `agent` binary once per mention
- Regex: `\$([A-Z]{1,5})` — matches 1-5 uppercase letters after `$`
- Deduplicates tickers per message, replies in-place (same stream + topic)
+- `flake.nix` — packages slopbot with `agent` from `/home/ben/src/bsima/agentd` as a Nix input
## Extending
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..6dfe14c
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+SERVICE_NAME="slopbot"
+SERVICE_FILE="${SERVICE_NAME}.service"
+SYSTEMD_SERVICE_PATH="/etc/systemd/system/${SERVICE_FILE}"
+NIX=(nix --extra-experimental-features 'nix-command flakes')
+
+if [[ ! -f .zuliprc ]]; then
+ echo "error: .zuliprc is missing. Copy .zuliprc.example and fill in bot credentials." >&2
+ exit 1
+fi
+
+if [[ ! -f .env ]]; then
+ cat > .env <<'EOF'
+SLOPBOT_AGENT_MODEL=parasail/kimi-k26
+SLOPBOT_WORKERS=4
+SLOPBOT_AGENT_WORKERS=1
+SLOPBOT_AGENT_TIMEOUT=120
+EOF
+ echo "created .env with slopbot defaults"
+ echo "edit .env if agent needs provider keys or config before restarting again"
+fi
+
+echo "locking local flake inputs..."
+"${NIX[@]}" flake lock
+
+echo "building slopbot..."
+"${NIX[@]}" build .#slopbot
+
+echo "installing systemd unit to ${SYSTEMD_SERVICE_PATH}..."
+sudo install -m 0644 "${SERVICE_FILE}" "${SYSTEMD_SERVICE_PATH}"
+
+echo "reloading systemd..."
+sudo systemctl daemon-reload
+
+echo "enabling and restarting ${SERVICE_NAME}..."
+sudo systemctl enable "${SERVICE_NAME}"
+sudo systemctl restart "${SERVICE_NAME}"
+
+echo "${SERVICE_NAME} status:"
+sudo systemctl --no-pager --full status "${SERVICE_NAME}"
+
+echo
+echo "follow logs with: sudo journalctl -u ${SERVICE_NAME} -f"
diff --git a/flake.lock b/flake.lock
index 94e0bfb..e903fc1 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,5 +1,25 @@
{
"nodes": {
+ "agentd": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1780590006,
+ "narHash": "sha256-Lq0SEdwHZ1POfHemmGUx9uiI325LHRl5ern//ipO68I=",
+ "ref": "refs/heads/t-1067",
+ "rev": "18516b65e28f409ac750b6d79dab5eff9940ee8c",
+ "revCount": 71,
+ "type": "git",
+ "url": "file:///home/ben/src/bsima/agentd"
+ },
+ "original": {
+ "type": "git",
+ "url": "file:///home/ben/src/bsima/agentd"
+ }
+ },
"nixpkgs": {
"locked": {
"lastModified": 1778869304,
@@ -18,6 +38,7 @@
},
"root": {
"inputs": {
+ "agentd": "agentd",
"nixpkgs": "nixpkgs"
}
}
diff --git a/flake.nix b/flake.nix
index 7a028d0..843f9c5 100644
--- a/flake.nix
+++ b/flake.nix
@@ -3,9 +3,13 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ agentd = {
+ url = "git+file:///home/ben/src/bsima/agentd";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
};
- outputs = { self, nixpkgs }:
+ outputs = { self, nixpkgs, agentd }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
@@ -16,19 +20,34 @@
ps.openai
ps.requests
]);
+
+ agent = pkgs.rustPlatform.buildRustPackage {
+ pname = "agent";
+ version = "0.1.0";
+ src = agentd;
+ cargoLock.lockFile = "${agentd}/Cargo.lock";
+ cargoBuildFlags = [ "-p" "agent" ];
+ doCheck = false;
+ };
in
{
devShells.${system}.default = pkgs.mkShell {
- buildInputs = [ pythonEnv ];
+ buildInputs = [ pythonEnv agent ];
shellHook = ''
echo "slopbot dev shell ready"
echo "Run: python main.py"
'';
};
- packages.${system}.default = pkgs.writeShellScriptBin "slopbot" ''
- cd /home/ben/src/bsima/slopbot
- exec ${pythonEnv}/bin/python ${./main.py}
- '';
+ packages.${system} = rec {
+ default = slopbot;
+ slopbot = pkgs.writeShellScriptBin "slopbot" ''
+ cd /home/ben/src/bsima/slopbot
+ export PATH=${agent}/bin:$PATH
+ export SLOPBOT_AGENT=${agent}/bin/agent
+ exec ${pythonEnv}/bin/python ${./main.py}
+ '';
+ inherit agent;
+ };
};
}
diff --git a/main.py b/main.py
index def25e4..5e1e61a 100644
--- a/main.py
+++ b/main.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
slopbot - Zulip bot that replies with stock prices when it sees $TICKER patterns,
-and delegates @mention responses to a one-shot agentd agent per message.
+and delegates @mention responses to a one-shot agent per message.
Usage:
python main.py
@@ -11,11 +11,13 @@ Requires a .zuliprc file in the same directory (see .zuliprc.example).
import html
import logging
+import os
import re
import subprocess
import time
-from datetime import datetime, UTC
+from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
+from threading import Semaphore
import yfinance as yf
import zulip
@@ -33,8 +35,11 @@ TICKER_RE = re.compile(r"\$([A-Z]{1,5})")
# Regex to detect @mention of slop / slopbot
MENTION_RE = re.compile(r"@\*\*slop[^*]*\*\*", re.IGNORECASE)
-AGENTD_RECV_TIMEOUT = 120 # seconds
-AGENTD_MODEL = "openai/gpt-5.5"
+AGENT_TIMEOUT = int(os.environ.get("SLOPBOT_AGENT_TIMEOUT", "120"))
+AGENT_MODEL = os.environ.get("SLOPBOT_AGENT_MODEL", "parasail/kimi-k26")
+AGENT_BIN = os.environ.get("SLOPBOT_AGENT", "agent")
+WORKERS = int(os.environ.get("SLOPBOT_WORKERS", "4"))
+AGENT_WORKERS = int(os.environ.get("SLOPBOT_AGENT_WORKERS", "1"))
HISTORY_LIMIT = 20
SYSTEM_PROMPT = """You are slop-bot in the meshheads.org Zulip chat: technically sharp,
@@ -50,6 +55,9 @@ log.info("Connecting to Zulip...")
client = zulip.Client(config_file=".zuliprc")
log.info("Connected as %s", client.email)
+executor = ThreadPoolExecutor(max_workers=WORKERS, thread_name_prefix="slopbot")
+agent_slots = Semaphore(AGENT_WORKERS)
+
# ---------------------------------------------------------------------------
# Zulip context
@@ -99,14 +107,19 @@ def fetch_topic_history(msg: dict, limit: int = HISTORY_LIMIT) -> str:
# ---------------------------------------------------------------------------
-# agentd one-shot helper
+# agent one-shot helper
# ---------------------------------------------------------------------------
-def _agentd(*args) -> tuple[int, str, str]:
- """Run agentd with args; return (returncode, stdout, stderr)."""
+def _agent(prompt: str) -> tuple[int, str, str]:
+ """Run agent once; return (returncode, stdout, stderr)."""
result = subprocess.run(
- ["/home/ben/omni/live/_/bin/agentd"] + list(args),
- capture_output=True, text=True,
+ [
+ AGENT_BIN,
+ "--model", AGENT_MODEL,
+ "--eval-cwd", str(Path(__file__).parent),
+ prompt,
+ ],
+ capture_output=True, text=True, timeout=AGENT_TIMEOUT,
)
return result.returncode, result.stdout.strip(), result.stderr.strip()
@@ -124,60 +137,33 @@ def _unique_session_name(msg: dict) -> str:
return f"slop-{stream_id}-{slug}-{ts}"
-def ask_agentd(msg: dict, content: str) -> str:
- """
- Run a one-shot agentd agent for a single mention.
- Flow: create → start → send → recv → stop
- No persistent session state; no stale-binary issues.
- """
+def ask_agent(msg: dict, content: str) -> str:
+ """Run a one-shot agent for a single mention."""
name = _unique_session_name(msg)
sender = msg.get("sender_full_name") or msg.get("sender_email") or "user"
- # Build system prompt with Zulip topic history baked in
topic_history = fetch_topic_history(msg)
prompt_parts = [SYSTEM_PROMPT]
if topic_history:
prompt_parts.append(f"\n\n{topic_history}")
- prompt_parts.append("\nYou have access to kagi_search and run_bash tools via agentd.")
- prompt_parts.append("\nYou are now active in this thread. Respond to the latest message.")
- system_prompt = "\n".join(prompt_parts)
-
- log.info("Creating one-shot agentd session: %s", name)
- rc, _, err = _agentd(
- "create", name, system_prompt,
- "--mode", "oneshot",
- "--model", AGENTD_MODEL,
- "--cwd", str(Path(__file__).parent),
- )
- if rc != 0:
- log.warning("agentd create failed for %s: %s", name, err)
- return "sorry, couldn't reach the agent"
-
- rc, _, err = _agentd("start", name)
- if rc != 0:
- log.warning("agentd start failed for %s: %s", name, err)
- return "sorry, couldn't start the agent"
-
- # Capture timestamp BEFORE send to avoid replaying old events
- ts_before = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
- user_msg = f"{sender}: {content}"
- rc, _, err = _agentd("send", name, user_msg)
- if rc != 0:
- log.warning("agentd send failed for %s: %s", name, err)
- _agentd("stop", name)
- return "sorry, couldn't send to the agent"
-
- rc, reply, err = _agentd(
- "recv", name,
- "--since", ts_before,
- "--timeout", str(AGENTD_RECV_TIMEOUT),
- )
+ prompt_parts.append("\nYou have access to the agent shell tools if you need them.")
+ prompt_parts.append("\nRespond to the latest Zulip message.")
+ prompt_parts.append(f"\nLatest message from {sender}:\n{content}")
+ prompt = "\n".join(prompt_parts)
- # Always clean up the session
- _agentd("stop", name)
+ log.info("Running one-shot agent: %s", name)
+ try:
+ with agent_slots:
+ rc, reply, err = _agent(prompt)
+ except subprocess.TimeoutExpired:
+ log.warning("agent timed out for %s", name)
+ return "sorry, agent timed out"
+ except Exception as e:
+ log.warning("agent failed for %s: %s", name, e)
+ return "sorry, couldn't reach the agent"
if rc != 0 or not reply:
- log.warning("agentd recv failed (rc=%d): %s", rc, err)
+ log.warning("agent failed for %s (rc=%d): %s", name, rc, err)
return "sorry, agent timed out"
return reply
@@ -204,38 +190,45 @@ def fetch_price(ticker: str) -> str:
# Event handler
# ---------------------------------------------------------------------------
-def handle_event(event: dict) -> None:
- """Process a Zulip event and reply to mentions and/or tickers."""
- if event.get("type") != "message":
- return
- msg = event["message"]
- if msg["sender_email"] == client.email:
- return
+def handle_message(msg: dict) -> None:
+ """Process a Zulip message and reply to mentions and/or tickers."""
+ try:
+ if msg["sender_email"] == client.email:
+ return
+
+ content = msg.get("content", "")
+ has_mention = MENTION_RE.search(content)
+ tickers = TICKER_RE.findall(content.upper())
+
+ if not has_mention and not tickers:
+ return
+
+ if has_mention:
+ cleaned = MENTION_RE.sub("", content).strip()
+ reply = ask_agent(msg, cleaned)
+ if tickers:
+ prices = [fetch_price(t) for t in dict.fromkeys(tickers)]
+ reply += "\n\n" + "\n".join(prices)
+ else:
+ lines = [fetch_price(t) for t in dict.fromkeys(tickers)]
+ reply = "\n".join(lines)
+
+ client.send_message({
+ "type": msg["type"],
+ "to": msg["stream_id"] if msg["type"] == "stream" else msg["sender_email"],
+ "topic": msg.get("subject", ""),
+ "content": reply,
+ })
+ log.info("Reply sent.")
+ except Exception:
+ log.exception("Failed to handle message %s", msg.get("id"))
- content = msg.get("content", "")
- has_mention = MENTION_RE.search(content)
- tickers = TICKER_RE.findall(content.upper())
- if not has_mention and not tickers:
+def handle_event(event: dict) -> None:
+ """Queue Zulip message work without blocking the event loop."""
+ if event.get("type") != "message":
return
-
- if has_mention:
- cleaned = MENTION_RE.sub("", content).strip()
- reply = ask_agentd(msg, cleaned)
- if tickers:
- prices = [fetch_price(t) for t in dict.fromkeys(tickers)]
- reply += "\n\n" + "\n".join(prices)
- else:
- lines = [fetch_price(t) for t in dict.fromkeys(tickers)]
- reply = "\n".join(lines)
-
- client.send_message({
- "type": msg["type"],
- "to": msg["stream_id"] if msg["type"] == "stream" else msg["sender_email"],
- "topic": msg.get("subject", ""),
- "content": reply,
- })
- log.info("Reply sent.")
+ executor.submit(handle_message, event["message"])
def main() -> None: