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/__init__.py +9 -0
- agentirc/__main__.py +97 -0
- agentirc/api.py +369 -0
- agentirc/bot.py +437 -0
- agentirc/config.py +114 -0
- agentirc/history.py +202 -0
- agentirc/models.py +156 -0
- agentirc/tools.py +81 -0
- agentirc-1.0.0.dist-info/METADATA +115 -0
- agentirc-1.0.0.dist-info/RECORD +20 -0
- agentirc-1.0.0.dist-info/WHEEL +4 -0
- agentirc-1.0.0.dist-info/entry_points.txt +2 -0
- agentirc-1.0.0.dist-info/licenses/LICENSE +21 -0
- ircbot/__init__.py +15 -0
- ircbot/__main__.py +41 -0
- ircbot/bot.py +279 -0
- ircbot/commands.py +52 -0
- ircbot/config.py +64 -0
- ircbot/connection.py +133 -0
- ircbot/protocol.py +109 -0
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
|
+
)
|