agentirc 1.0.0__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.
agentirc/bot.py ADDED
@@ -0,0 +1,437 @@
1
+ """AI-powered IRC agent that wraps the base IRCBot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+
8
+ from ircbot import IRCBot, IRCMessage, register_builtins
9
+ from .api import ResponsesClient
10
+ from .config import ChatConfig
11
+ from .history import HistoryStore
12
+ from .models import (
13
+ KNOWN_PROVIDERS,
14
+ pick_default_model,
15
+ provider_for_model,
16
+ )
17
+ from .tools import build_tools, tools_for_model
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ class ChatBot:
23
+ """Wrap IRCBot with multi-provider AI chat capabilities."""
24
+
25
+ def __init__(self, config: ChatConfig) -> None:
26
+ self.config = config
27
+ self.bot = IRCBot(config.irc)
28
+
29
+ self.models = {
30
+ provider: list(config.models.get(provider, []))
31
+ for provider in KNOWN_PROVIDERS
32
+ }
33
+ self.default_model = pick_default_model(self.models, config.default_model)
34
+ self.model = self.default_model
35
+ self.default_personality = config.default_personality
36
+ self.personality = self.default_personality
37
+ self.tools_enabled = True
38
+ self.verbose = False
39
+ self.search_country_enabled = bool(config.web_search_country)
40
+
41
+ store_path = None
42
+ encryption_key = None
43
+ if config.history_encryption_key:
44
+ store_path = "."
45
+ encryption_key = config.history_encryption_key
46
+
47
+ self.history = HistoryStore(
48
+ prompt_prefix=config.prompt_prefix,
49
+ prompt_suffix=config.prompt_suffix,
50
+ personality=config.default_personality,
51
+ prompt_suffix_extra=config.prompt_suffix_extra,
52
+ max_items=24,
53
+ store_path=store_path,
54
+ encryption_key=encryption_key,
55
+ )
56
+
57
+ self._user_models: dict[str, dict[str, str]] = {}
58
+
59
+ provider = self._provider_for_model(self.model)
60
+ self.client = ResponsesClient(
61
+ api_base=self._base_url(provider),
62
+ api_key=self._api_key(provider),
63
+ model=self.model,
64
+ system_prompt=self._default_prompt(),
65
+ max_tokens=config.max_tokens,
66
+ enabled_tools=config.tools,
67
+ provider=provider,
68
+ )
69
+
70
+ log.info("Using default model: %s (%s)", self.model, provider)
71
+ self._register_commands()
72
+
73
+ def _register_commands(self) -> None:
74
+ """Register built-in IRC commands and AI commands."""
75
+ register_builtins(self.bot)
76
+
77
+ @self.bot.command("ai", help="Talk to the AI: !chat <message>", aliases=["chat", "ask"])
78
+ async def cmd_chat(bot: IRCBot, msg: IRCMessage, args: str) -> None:
79
+ if not args.strip():
80
+ await bot.reply(msg, "Usage: !chat <message>")
81
+ return
82
+ await self._respond(bot, msg, msg.nick, args.strip())
83
+
84
+ @self.bot.command("x", help="Talk as another user: !x <nick> <message>")
85
+ async def cmd_x(bot: IRCBot, msg: IRCMessage, args: str) -> None:
86
+ parts = args.strip().split(None, 1)
87
+ if len(parts) < 2:
88
+ await bot.reply(msg, "Usage: !x <nick> <message>")
89
+ return
90
+ target_nick, text = parts[0], parts[1].strip()
91
+ if not text:
92
+ await bot.reply(msg, "Usage: !x <nick> <message>")
93
+ return
94
+ await self._respond(bot, msg, target_nick, text)
95
+
96
+ @self.bot.command("persona", help="Set persona and reintroduce: !persona <text>")
97
+ async def cmd_persona(bot: IRCBot, msg: IRCMessage, args: str) -> None:
98
+ room, user = self._thread_key(msg, msg.nick)
99
+ persona = args.strip() or self.default_personality
100
+ self.history.init_prompt(room, user, persona=persona)
101
+ self.history.add(room, user, "user", "introduce yourself")
102
+ await self._respond(bot, msg, msg.nick)
103
+
104
+ @self.bot.command("custom", help="Set custom system prompt: !custom <prompt>")
105
+ async def cmd_custom(bot: IRCBot, msg: IRCMessage, args: str) -> None:
106
+ custom = args.strip()
107
+ if not custom:
108
+ await bot.reply(msg, "Usage: !custom <prompt>")
109
+ return
110
+ room, user = self._thread_key(msg, msg.nick)
111
+ self.history.init_prompt(room, user, custom=custom)
112
+ self.history.add(room, user, "user", "introduce yourself")
113
+ await self._respond(bot, msg, msg.nick)
114
+
115
+ @self.bot.command("reset", help="Reset your AI conversation to default settings")
116
+ async def cmd_reset(bot: IRCBot, msg: IRCMessage, _args: str) -> None:
117
+ room, user = self._thread_key(msg, msg.nick)
118
+ self.history.reset(room, user, stock=False)
119
+ await bot.reply(msg, f"{self.bot.config.nick} reset to default for {msg.nick}")
120
+
121
+ @self.bot.command("stock", help="Reset your AI conversation with no system prompt")
122
+ async def cmd_stock(bot: IRCBot, msg: IRCMessage, _args: str) -> None:
123
+ room, user = self._thread_key(msg, msg.nick)
124
+ self.history.reset(room, user, stock=True)
125
+ await bot.reply(msg, f"Stock settings applied for {msg.nick}")
126
+
127
+ @self.bot.command("mymodel", help="Show or set your model: !mymodel [name]")
128
+ async def cmd_mymodel(bot: IRCBot, msg: IRCMessage, args: str) -> None:
129
+ await self._refresh_models()
130
+ room, user = self._thread_key(msg, msg.nick)
131
+ requested = args.strip()
132
+ if not requested:
133
+ current = self._user_models.get(room, {}).get(user, self.model)
134
+ await bot.reply(msg, f"Your current model: {current}")
135
+ await bot.reply(msg, f"Available models: {', '.join(self._all_models())}")
136
+ return
137
+ if not self._is_valid_model(requested):
138
+ await bot.reply(msg, f"Model '{requested}' not found. Available: {', '.join(self._all_models())}")
139
+ return
140
+ self._user_models.setdefault(room, {})[user] = requested
141
+ await bot.reply(msg, f"Model for {msg.nick} set to {requested}")
142
+
143
+ @self.bot.command("model", help="Admin: show/set global model: !model [name|reset]")
144
+ async def cmd_model(bot: IRCBot, msg: IRCMessage, args: str) -> None:
145
+ if not self._is_admin(msg.nick):
146
+ await bot.reply(msg, "Admin only.")
147
+ return
148
+
149
+ requested = args.strip()
150
+ if not requested:
151
+ await self._refresh_models()
152
+ await bot.reply(msg, f"Current model: {self.model}")
153
+ for line in self._models_by_provider_lines():
154
+ await bot.reply(msg, line)
155
+ return
156
+ if requested.lower() == "reset":
157
+ self.model = self.default_model
158
+ await bot.reply(msg, f"Model set to {self.model}")
159
+ return
160
+ if self._is_valid_model(requested):
161
+ self.model = requested
162
+ await bot.reply(msg, f"Model set to {self.model}")
163
+ return
164
+ await bot.reply(msg, f"Model '{requested}' not found.")
165
+
166
+ @self.bot.command("tools", help="Admin: !tools [on|off|toggle|status]")
167
+ async def cmd_tools(bot: IRCBot, msg: IRCMessage, args: str) -> None:
168
+ if not self._is_admin(msg.nick):
169
+ await bot.reply(msg, "Admin only.")
170
+ return
171
+ arg = args.strip().lower()
172
+ if arg in ("", "status"):
173
+ state = "enabled" if self.tools_enabled else "disabled"
174
+ await bot.reply(msg, f"Tools are currently {state}")
175
+ return
176
+ if arg in ("on", "enable", "enabled"):
177
+ self.tools_enabled = True
178
+ elif arg in ("off", "disable", "disabled"):
179
+ self.tools_enabled = False
180
+ else:
181
+ self.tools_enabled = not self.tools_enabled
182
+ state = "enabled" if self.tools_enabled else "disabled"
183
+ await bot.reply(msg, f"Tools are now {state}")
184
+
185
+ @self.bot.command("verbose", help="Admin: !verbose [on|off|toggle|status]")
186
+ async def cmd_verbose(bot: IRCBot, msg: IRCMessage, args: str) -> None:
187
+ if not self._is_admin(msg.nick):
188
+ await bot.reply(msg, "Admin only.")
189
+ return
190
+ arg = args.strip().lower()
191
+ if arg in ("", "status"):
192
+ await bot.reply(msg, f"Verbose mode is {'ON' if self.verbose else 'OFF'}")
193
+ return
194
+ if arg in ("on", "true", "1", "enable", "enabled"):
195
+ self.verbose = True
196
+ elif arg in ("off", "false", "0", "disable", "disabled"):
197
+ self.verbose = False
198
+ elif arg in ("toggle", "switch"):
199
+ self.verbose = not self.verbose
200
+ else:
201
+ await bot.reply(msg, "Usage: !verbose [on|off|toggle]")
202
+ return
203
+ await bot.reply(msg, f"Verbose mode set to {'ON' if self.verbose else 'OFF'}")
204
+
205
+ @self.bot.command("clear", help="Admin: clear all conversation state")
206
+ async def cmd_clear(bot: IRCBot, msg: IRCMessage, _args: str) -> None:
207
+ if not self._is_admin(msg.nick):
208
+ await bot.reply(msg, "Admin only.")
209
+ return
210
+ self.history.clear_all()
211
+ self._user_models.clear()
212
+ self.model = self.default_model
213
+ self.personality = self.default_personality
214
+ await bot.reply(msg, "Bot has been reset for everyone.")
215
+
216
+ @self.bot.command("country", help="Admin: toggle search country filtering: !country [on|off|status]")
217
+ async def cmd_country(bot: IRCBot, msg: IRCMessage, args: str) -> None:
218
+ if not self._is_admin(msg.nick):
219
+ await bot.reply(msg, "Admin only.")
220
+ return
221
+ country = self.config.web_search_country
222
+ if not country:
223
+ await bot.reply(msg, "No search country configured (WEB_SEARCH_COUNTRY not set).")
224
+ return
225
+ arg = args.strip().lower()
226
+ if arg in ("", "status"):
227
+ state = "enabled" if self.search_country_enabled else "disabled"
228
+ await bot.reply(msg, f"Search country filtering ({country}): {state}")
229
+ return
230
+ if arg in ("on", "enable", "enabled"):
231
+ self.search_country_enabled = True
232
+ elif arg in ("off", "disable", "disabled"):
233
+ self.search_country_enabled = False
234
+ else:
235
+ self.search_country_enabled = not self.search_country_enabled
236
+ state = "enabled" if self.search_country_enabled else "disabled"
237
+ await bot.reply(msg, f"Search country filtering ({country}): {state}")
238
+
239
+ @self.bot.command("location", help="Set your location: !location <place> | !location clear")
240
+ async def cmd_location(bot: IRCBot, msg: IRCMessage, args: str) -> None:
241
+ _room, user = self._thread_key(msg, msg.nick)
242
+ arg = args.strip()
243
+ if not arg:
244
+ loc = self.history.get_location(user)
245
+ if loc:
246
+ await bot.reply(msg, f"Your location: {loc}")
247
+ else:
248
+ await bot.reply(msg, "No location set. Usage: !location <place>")
249
+ return
250
+ if arg.lower() in ("clear", "remove", "reset", "none"):
251
+ self.history.set_location(user, "")
252
+ await bot.reply(msg, "Location cleared.")
253
+ return
254
+ self.history.set_location(user, arg)
255
+ await bot.reply(msg, f"Location set to: {arg}")
256
+
257
+ @self.bot.command("join", help="Admin: join a channel: !join <#channel>")
258
+ async def cmd_join(bot: IRCBot, msg: IRCMessage, args: str) -> None:
259
+ if not self._is_admin(msg.nick):
260
+ await bot.reply(msg, "Admin only.")
261
+ return
262
+ channel = args.strip()
263
+ if not channel:
264
+ await bot.reply(msg, "Usage: !join <#channel>")
265
+ return
266
+ if not channel.startswith(("#", "&", "!", "+")):
267
+ channel = f"#{channel}"
268
+ await bot.join(channel)
269
+ await bot.reply(msg, f"Joined {channel}")
270
+
271
+ @self.bot.command("part", help="Admin: leave a channel: !part [#channel] [reason]")
272
+ async def cmd_part(bot: IRCBot, msg: IRCMessage, args: str) -> None:
273
+ if not self._is_admin(msg.nick):
274
+ await bot.reply(msg, "Admin only.")
275
+ return
276
+ parts = args.strip().split(None, 1)
277
+ if parts and parts[0].startswith(("#", "&", "!", "+")):
278
+ channel = parts[0]
279
+ reason = parts[1] if len(parts) > 1 else ""
280
+ elif msg.is_channel:
281
+ channel = msg.target
282
+ reason = args.strip()
283
+ else:
284
+ await bot.reply(msg, "Usage: !part <#channel> [reason]")
285
+ return
286
+ await bot.part(channel, reason)
287
+
288
+ def _thread_key(self, msg: IRCMessage, user_nick: str) -> tuple[str, str]:
289
+ room = msg.target.lower() if msg.is_channel else "__dm__"
290
+ return (room, user_nick.lower())
291
+
292
+ def _provider_for_model(self, model: str) -> str:
293
+ provider = provider_for_model(model, self.models)
294
+ if provider:
295
+ return provider
296
+ configured = [p for p in KNOWN_PROVIDERS if self._base_url(p)]
297
+ if len(configured) == 1:
298
+ return configured[0]
299
+ return "openai"
300
+
301
+ def _base_url(self, provider: str) -> str:
302
+ return str(self.config.base_urls.get(provider, "") or "").strip()
303
+
304
+ def _api_key(self, provider: str) -> str:
305
+ return str(self.config.api_keys.get(provider, "") or "").strip()
306
+
307
+ def _is_admin(self, nick: str) -> bool:
308
+ return nick.lower() in set(self.config.admins)
309
+
310
+ def _all_models(self) -> list[str]:
311
+ values: list[str] = []
312
+ seen = set()
313
+ for provider in KNOWN_PROVIDERS:
314
+ for model in self.models.get(provider, []):
315
+ if model not in seen:
316
+ values.append(model)
317
+ seen.add(model)
318
+ return values
319
+
320
+ def _is_valid_model(self, model: str) -> bool:
321
+ return model in set(self._all_models())
322
+
323
+ @staticmethod
324
+ def _provider_label(provider: str) -> str:
325
+ if provider == "xai":
326
+ return "xAI"
327
+ if provider == "openai":
328
+ return "OpenAI"
329
+ if provider == "lmstudio":
330
+ return "LM Studio"
331
+ return provider
332
+
333
+ def _models_by_provider_lines(self) -> list[str]:
334
+ lines: list[str] = []
335
+ for provider in KNOWN_PROVIDERS:
336
+ items = self.models.get(provider, [])
337
+ if not items:
338
+ continue
339
+ lines.append(f"{self._provider_label(provider)}: {', '.join(items)}")
340
+ return lines or ["No models available."]
341
+
342
+ async def _refresh_models(self) -> None:
343
+ if not self.config.server_models:
344
+ return
345
+ merged = dict(self.models)
346
+ for provider in KNOWN_PROVIDERS:
347
+ api_base = self._base_url(provider)
348
+ api_key = self._api_key(provider)
349
+ if provider == "lmstudio":
350
+ if not api_base:
351
+ continue
352
+ elif not api_key:
353
+ continue
354
+ try:
355
+ fetched = await self.client.list_models(
356
+ provider,
357
+ api_base=api_base,
358
+ api_key=api_key,
359
+ )
360
+ except Exception:
361
+ log.exception("Failed to refresh model list from %s", provider)
362
+ continue
363
+ if not fetched:
364
+ continue
365
+ configured = list(self.models.get(provider, []))
366
+ merged[provider] = sorted(dict.fromkeys([*fetched, *configured]))
367
+ self.models = merged
368
+
369
+ def _default_prompt(self) -> str:
370
+ if self.config.default_system_prompt:
371
+ return self.config.default_system_prompt
372
+ extra = "" if self.verbose else self.config.prompt_suffix_extra
373
+ return (
374
+ f"{self.config.prompt_prefix}"
375
+ f"{self.personality}"
376
+ f"{self.config.prompt_suffix}"
377
+ f"{extra}"
378
+ ).strip()
379
+
380
+ @staticmethod
381
+ def _clean_response_text(text: str) -> str:
382
+ cleaned = text or ""
383
+ if "</think>" in cleaned and "<think>" in cleaned:
384
+ try:
385
+ cleaned = cleaned.split("</think>", 1)[1]
386
+ except Exception:
387
+ pass
388
+ if "<|begin_of_solution|>" in cleaned and "<|end_of_solution|>" in cleaned:
389
+ try:
390
+ cleaned = cleaned.split("<|begin_of_solution|>", 1)[1].split(
391
+ "<|end_of_solution|>",
392
+ 1,
393
+ )[0]
394
+ except Exception:
395
+ pass
396
+ return cleaned.strip()
397
+
398
+ async def _respond(self, bot: IRCBot, msg: IRCMessage, user_nick: str, text: str | None = None) -> None:
399
+ room, user = self._thread_key(msg, user_nick)
400
+ if text:
401
+ self.history.add(room, user, "user", text)
402
+ messages = self.history.get(room, user)
403
+ model = self._user_models.get(room, {}).get(user, self.model)
404
+ provider = self._provider_for_model(model)
405
+ api_base = self._base_url(provider)
406
+ if not api_base:
407
+ await bot.reply(msg, f"No API base configured for provider '{provider}'.")
408
+ return
409
+
410
+ tool_names = tools_for_model(self.config.tools, provider, model) if self.tools_enabled else []
411
+ country = self.config.web_search_country if self.search_country_enabled else ""
412
+ tools = build_tools(tool_names, provider, web_search_country=country)
413
+ try:
414
+ reply, _response_id = await self.client.ask_messages(
415
+ messages,
416
+ model=model,
417
+ provider=provider,
418
+ api_base=api_base,
419
+ api_key=self._api_key(provider),
420
+ built_tools=tools,
421
+ max_tokens=self.config.max_tokens,
422
+ )
423
+ except Exception:
424
+ log.exception("AI request failed")
425
+ await bot.reply(msg, "AI request failed.")
426
+ return
427
+
428
+ cleaned = self._clean_response_text(reply)
429
+ self.history.add(room, user, "assistant", cleaned)
430
+ for line in cleaned.splitlines():
431
+ if line.strip():
432
+ await bot.reply(msg, line)
433
+
434
+ async def run(self) -> None:
435
+ """Start the agent."""
436
+ await self._refresh_models()
437
+ await self.bot.run()
agentirc/config.py ADDED
@@ -0,0 +1,114 @@
1
+ """Configuration for the AI agent layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+
8
+ from ircbot.config import BotConfig, load_env
9
+
10
+ _DEFAULT_PERSONALITY = "a helpful IRC chatbot"
11
+ _DEFAULT_PROMPT_PREFIX = "You are "
12
+ _DEFAULT_PROMPT_SUFFIX = "."
13
+ _DEFAULT_PROMPT_SUFFIX_EXTRA = " Keep responses concise (under 400 chars) since this is IRC."
14
+
15
+
16
+ def _parse_csv(value: str | None) -> list[str]:
17
+ if not value:
18
+ return []
19
+ return [part.strip() for part in value.split(",") if part.strip()]
20
+
21
+
22
+ def _parse_bool(value: str | None, default: bool = False) -> bool:
23
+ if value is None:
24
+ return default
25
+ return value.strip().lower() in {"1", "true", "yes", "on"}
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ChatConfig:
30
+ """AI agent configuration."""
31
+
32
+ irc: BotConfig
33
+ models: dict[str, list[str]]
34
+ api_keys: dict[str, str]
35
+ base_urls: dict[str, str]
36
+ default_model: str
37
+ default_personality: str = _DEFAULT_PERSONALITY
38
+ prompt_prefix: str = _DEFAULT_PROMPT_PREFIX
39
+ prompt_suffix: str = _DEFAULT_PROMPT_SUFFIX
40
+ prompt_suffix_extra: str = _DEFAULT_PROMPT_SUFFIX_EXTRA
41
+ default_system_prompt: str = ""
42
+ max_tokens: int = 300
43
+ tools: list[str] = field(default_factory=lambda: ["web_search", "x_search", "code_interpreter"])
44
+ admins: list[str] = field(default_factory=list)
45
+ server_models: bool = True
46
+ web_search_country: str = ""
47
+ history_encryption_key: str = ""
48
+
49
+ def make_default_prompt(self, *, verbose: bool = False) -> str:
50
+ """Build the default system prompt for a new conversation."""
51
+ if self.default_system_prompt:
52
+ return self.default_system_prompt.strip()
53
+ extra = "" if verbose else self.prompt_suffix_extra
54
+ return f"{self.prompt_prefix}{self.default_personality}{self.prompt_suffix}{extra}".strip()
55
+
56
+ @classmethod
57
+ def from_env(cls) -> ChatConfig:
58
+ """Build config from environment variables."""
59
+ load_env()
60
+ openai_models = _parse_csv(os.environ.get("OPENAI_MODELS"))
61
+ xai_models = _parse_csv(os.environ.get("XAI_MODELS"))
62
+ lmstudio_models = _parse_csv(os.environ.get("LMSTUDIO_MODELS"))
63
+
64
+ legacy_openai_model = os.environ.get("OPENAI_MODEL", "").strip()
65
+ if legacy_openai_model and legacy_openai_model not in openai_models:
66
+ openai_models = [legacy_openai_model, *openai_models]
67
+
68
+ default_model = os.environ.get("DEFAULT_MODEL", "").strip()
69
+ if not default_model:
70
+ default_model = (
71
+ legacy_openai_model
72
+ or (openai_models[0] if openai_models else "")
73
+ or (xai_models[0] if xai_models else "")
74
+ or (lmstudio_models[0] if lmstudio_models else "")
75
+ )
76
+
77
+ return cls(
78
+ irc=BotConfig.from_env(),
79
+ models={
80
+ "openai": openai_models,
81
+ "xai": xai_models,
82
+ "lmstudio": lmstudio_models,
83
+ },
84
+ api_keys={
85
+ "openai": os.environ.get("OPENAI_API_KEY", "").strip(),
86
+ "xai": os.environ.get("XAI_API_KEY", "").strip(),
87
+ "lmstudio": os.environ.get("LMSTUDIO_API_KEY", "").strip(),
88
+ },
89
+ base_urls={
90
+ "openai": os.environ.get("OPENAI_API_BASE", "https://api.openai.com").strip(),
91
+ "xai": os.environ.get("XAI_API_BASE", "https://api.x.ai/v1").strip(),
92
+ "lmstudio": os.environ.get("LMSTUDIO_BASE_URL", "http://127.0.0.1:1234/v1").strip(),
93
+ },
94
+ default_model=default_model,
95
+ default_personality=os.environ.get(
96
+ "AGENTIRC_DEFAULT_PERSONALITY",
97
+ os.environ.get("AGENTIRC_PERSONALITY", _DEFAULT_PERSONALITY),
98
+ ).strip()
99
+ or _DEFAULT_PERSONALITY,
100
+ prompt_prefix=os.environ.get("AGENTIRC_PROMPT_PREFIX", _DEFAULT_PROMPT_PREFIX),
101
+ prompt_suffix=os.environ.get("AGENTIRC_PROMPT_SUFFIX", _DEFAULT_PROMPT_SUFFIX),
102
+ prompt_suffix_extra=os.environ.get("AGENTIRC_PROMPT_SUFFIX_EXTRA", _DEFAULT_PROMPT_SUFFIX_EXTRA),
103
+ default_system_prompt=os.environ.get("AGENTIRC_SYSTEM_PROMPT", "").strip(),
104
+ max_tokens=int(os.environ.get("AGENTIRC_MAX_TOKENS", "300")),
105
+ tools=[
106
+ t.strip()
107
+ for t in os.environ.get("AGENTIRC_TOOLS", "web_search,x_search,code_interpreter").split(",")
108
+ if t.strip()
109
+ ],
110
+ admins=[nick.lower() for nick in _parse_csv(os.environ.get("AGENTIRC_ADMINS"))],
111
+ server_models=_parse_bool(os.environ.get("AGENTIRC_SERVER_MODELS"), True),
112
+ web_search_country=os.environ.get("WEB_SEARCH_COUNTRY", "").strip(),
113
+ history_encryption_key=os.environ.get("HISTORY_ENCRYPTION_KEY", "").strip(),
114
+ )