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/__init__.py
ADDED
agentirc/__main__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Entry point: python -m agentirc"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from .config import ChatConfig
|
|
9
|
+
from .bot import ChatBot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
parser = argparse.ArgumentParser(description="AI-powered IRC agent")
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--generate-key",
|
|
16
|
+
action="store_true",
|
|
17
|
+
help="Generate a Fernet encryption key for history persistence and exit",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--env-file",
|
|
21
|
+
metavar="PATH",
|
|
22
|
+
default=".env",
|
|
23
|
+
help="Path to .env file (default: .env)",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--debug",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="Enable debug logging",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--host",
|
|
32
|
+
metavar="HOST",
|
|
33
|
+
help="IRC server hostname (overrides IRC_HOST)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--port",
|
|
37
|
+
type=int,
|
|
38
|
+
metavar="PORT",
|
|
39
|
+
help="IRC server port (overrides IRC_PORT)",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--nick",
|
|
43
|
+
metavar="NICK",
|
|
44
|
+
help="Bot nickname (overrides IRC_NICK)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--channels",
|
|
48
|
+
metavar="CHANS",
|
|
49
|
+
help="Comma-separated channels to join (overrides IRC_CHANNELS)",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--tls",
|
|
53
|
+
action="store_true",
|
|
54
|
+
default=None,
|
|
55
|
+
help="Connect with TLS (overrides IRC_USE_TLS)",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--model",
|
|
59
|
+
metavar="MODEL",
|
|
60
|
+
help="Default model (overrides DEFAULT_MODEL)",
|
|
61
|
+
)
|
|
62
|
+
args = parser.parse_args()
|
|
63
|
+
|
|
64
|
+
if args.generate_key:
|
|
65
|
+
from cryptography.fernet import Fernet
|
|
66
|
+
print(Fernet.generate_key().decode())
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
# Load .env first, then apply CLI overrides (which win via direct set)
|
|
70
|
+
from ircbot.config import load_env
|
|
71
|
+
load_env(args.env_file)
|
|
72
|
+
|
|
73
|
+
if args.host:
|
|
74
|
+
os.environ["IRC_HOST"] = args.host
|
|
75
|
+
if args.port is not None:
|
|
76
|
+
os.environ["IRC_PORT"] = str(args.port)
|
|
77
|
+
if args.nick:
|
|
78
|
+
os.environ["IRC_NICK"] = args.nick
|
|
79
|
+
if args.channels:
|
|
80
|
+
os.environ["IRC_CHANNELS"] = args.channels
|
|
81
|
+
if args.tls:
|
|
82
|
+
os.environ["IRC_USE_TLS"] = "true"
|
|
83
|
+
if args.model:
|
|
84
|
+
os.environ["DEFAULT_MODEL"] = args.model
|
|
85
|
+
|
|
86
|
+
logging.basicConfig(
|
|
87
|
+
level=logging.DEBUG if args.debug else logging.INFO,
|
|
88
|
+
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
config = ChatConfig.from_env()
|
|
92
|
+
bot = ChatBot(config)
|
|
93
|
+
asyncio.run(bot.run())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
main()
|
agentirc/api.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""OpenAI-compatible Responses API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Iterable
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .tools import build_tools
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ResponsesClient:
|
|
18
|
+
"""Subset of the agent-smithers Responses client adapted for IRC."""
|
|
19
|
+
|
|
20
|
+
LMSTUDIO_FALLBACK_USER_PROMPT = "Please continue the conversation."
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
api_base: str,
|
|
25
|
+
api_key: str,
|
|
26
|
+
model: str,
|
|
27
|
+
system_prompt: str,
|
|
28
|
+
max_tokens: int,
|
|
29
|
+
enabled_tools: list[str],
|
|
30
|
+
provider: str = "openai",
|
|
31
|
+
) -> None:
|
|
32
|
+
self.api_base = api_base.rstrip("/")
|
|
33
|
+
self.api_key = api_key
|
|
34
|
+
self.model = model
|
|
35
|
+
self.provider = provider
|
|
36
|
+
self.system_prompt = system_prompt
|
|
37
|
+
self.max_tokens = max_tokens
|
|
38
|
+
self.enabled_tools = list(enabled_tools)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _fallback_base_url(provider: str) -> str:
|
|
42
|
+
if provider == "lmstudio":
|
|
43
|
+
return "http://127.0.0.1:1234/v1"
|
|
44
|
+
if provider == "xai":
|
|
45
|
+
return "https://api.x.ai/v1"
|
|
46
|
+
return "https://api.openai.com/v1"
|
|
47
|
+
|
|
48
|
+
def _base_url(self, provider: str, api_base: str | None = None) -> str:
|
|
49
|
+
configured = str(api_base or self.api_base or "").strip()
|
|
50
|
+
base = configured or self._fallback_base_url(provider)
|
|
51
|
+
if base.endswith("/v1"):
|
|
52
|
+
return base
|
|
53
|
+
return f"{base}/v1"
|
|
54
|
+
|
|
55
|
+
def _headers(self, provider: str, api_key: str | None = None) -> dict[str, str]:
|
|
56
|
+
headers = {"Content-Type": "application/json"}
|
|
57
|
+
token = str(self.api_key if api_key is None else api_key).strip()
|
|
58
|
+
if token:
|
|
59
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
60
|
+
return headers
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _supports_instructions(provider: str) -> bool:
|
|
64
|
+
return provider != "xai"
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _has_user_message(items: Iterable[dict[str, Any]]) -> bool:
|
|
68
|
+
for item in items:
|
|
69
|
+
if not isinstance(item, dict):
|
|
70
|
+
continue
|
|
71
|
+
if str(item.get("role") or "").strip() == "user" and str(item.get("content") or "").strip():
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def _ensure_lmstudio_user_message(
|
|
77
|
+
cls,
|
|
78
|
+
items: Iterable[dict[str, Any]],
|
|
79
|
+
) -> list[dict[str, Any]]:
|
|
80
|
+
final_items = [dict(item) for item in items if isinstance(item, dict)]
|
|
81
|
+
if cls._has_user_message(final_items):
|
|
82
|
+
return final_items
|
|
83
|
+
final_items.append({"role": "user", "content": cls.LMSTUDIO_FALLBACK_USER_PROMPT})
|
|
84
|
+
return final_items
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _merge_include_items(existing: Any, additions: Iterable[str]) -> list[str]:
|
|
88
|
+
merged: list[str] = []
|
|
89
|
+
seen = set()
|
|
90
|
+
for value in existing if isinstance(existing, list) else []:
|
|
91
|
+
if isinstance(value, str) and value and value not in seen:
|
|
92
|
+
merged.append(value)
|
|
93
|
+
seen.add(value)
|
|
94
|
+
for value in additions:
|
|
95
|
+
if value and value not in seen:
|
|
96
|
+
merged.append(value)
|
|
97
|
+
seen.add(value)
|
|
98
|
+
return merged
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _is_chat_model(provider: str, model_id: str) -> bool:
|
|
102
|
+
lowered = model_id.lower()
|
|
103
|
+
if provider == "lmstudio":
|
|
104
|
+
blocked_models = {
|
|
105
|
+
"text-embedding-nomic-embed-text-v1.5",
|
|
106
|
+
}
|
|
107
|
+
if lowered in blocked_models:
|
|
108
|
+
return False
|
|
109
|
+
return bool(model_id.strip())
|
|
110
|
+
if provider == "xai":
|
|
111
|
+
if not lowered.startswith("grok-"):
|
|
112
|
+
return False
|
|
113
|
+
blocked_fragments = ("imagine", "image", "video", "voice", "vision")
|
|
114
|
+
return not any(fragment in lowered for fragment in blocked_fragments)
|
|
115
|
+
|
|
116
|
+
prefixes = ("gpt-", "o1", "o3", "o4")
|
|
117
|
+
if not model_id.startswith(prefixes):
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
blocked_fragments = (
|
|
121
|
+
"preview",
|
|
122
|
+
"audio",
|
|
123
|
+
"computer-use",
|
|
124
|
+
"transcribe",
|
|
125
|
+
"tts",
|
|
126
|
+
"image",
|
|
127
|
+
)
|
|
128
|
+
if any(fragment in lowered for fragment in blocked_fragments):
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
if re.search(r"-\d{4}-\d{2}-\d{2}$", lowered):
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def build_input_items(
|
|
138
|
+
messages: Iterable[dict[str, Any]],
|
|
139
|
+
*,
|
|
140
|
+
include_system: bool = False,
|
|
141
|
+
) -> tuple[str | None, list[dict[str, str]]]:
|
|
142
|
+
instructions: list[str] = []
|
|
143
|
+
input_items: list[dict[str, str]] = []
|
|
144
|
+
for message in messages:
|
|
145
|
+
role = str(message.get("role") or "").strip()
|
|
146
|
+
content = str(message.get("content") or "")
|
|
147
|
+
if not role or not content:
|
|
148
|
+
continue
|
|
149
|
+
if role == "system":
|
|
150
|
+
if include_system:
|
|
151
|
+
input_items.append({"role": role, "content": content})
|
|
152
|
+
else:
|
|
153
|
+
instructions.append(content)
|
|
154
|
+
continue
|
|
155
|
+
if role in {"user", "assistant"}:
|
|
156
|
+
input_items.append({"role": role, "content": content})
|
|
157
|
+
joined = "\n\n".join(part.strip() for part in instructions if part.strip()).strip()
|
|
158
|
+
return (joined or None, input_items)
|
|
159
|
+
|
|
160
|
+
def build_request_payload(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
model: str,
|
|
164
|
+
messages: Iterable[dict[str, Any]] | None = None,
|
|
165
|
+
tools: list[dict[str, Any]] | None = None,
|
|
166
|
+
tool_choice: str | None = None,
|
|
167
|
+
previous_response_id: str | None = None,
|
|
168
|
+
input_items: list[dict[str, Any]] | None = None,
|
|
169
|
+
options: dict[str, Any] | None = None,
|
|
170
|
+
instructions: str | None = None,
|
|
171
|
+
provider: str | None = None,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
provider_name = provider or self.provider
|
|
174
|
+
payload: dict[str, Any] = {"model": model}
|
|
175
|
+
derived_instructions = instructions
|
|
176
|
+
derived_input: list[dict[str, Any]] = []
|
|
177
|
+
supports_instructions = self._supports_instructions(provider_name)
|
|
178
|
+
if messages is not None:
|
|
179
|
+
derived_instructions, derived_input = self.build_input_items(
|
|
180
|
+
messages,
|
|
181
|
+
include_system=not supports_instructions,
|
|
182
|
+
)
|
|
183
|
+
if previous_response_id:
|
|
184
|
+
payload["previous_response_id"] = previous_response_id
|
|
185
|
+
if supports_instructions and derived_instructions and not previous_response_id:
|
|
186
|
+
payload["instructions"] = derived_instructions
|
|
187
|
+
final_input = input_items if input_items is not None else derived_input
|
|
188
|
+
if provider_name == "lmstudio" and messages is not None and input_items is None:
|
|
189
|
+
final_input = self._ensure_lmstudio_user_message(final_input)
|
|
190
|
+
if final_input:
|
|
191
|
+
payload["input"] = final_input
|
|
192
|
+
if tools:
|
|
193
|
+
payload["tools"] = tools
|
|
194
|
+
payload["tool_choice"] = tool_choice or "auto"
|
|
195
|
+
if options:
|
|
196
|
+
for key, value in options.items():
|
|
197
|
+
if value is not None:
|
|
198
|
+
payload[key] = value
|
|
199
|
+
payload["store"] = False
|
|
200
|
+
|
|
201
|
+
if provider_name == "xai" and any(
|
|
202
|
+
isinstance(tool, dict) and tool.get("type") in {"web_search", "x_search"}
|
|
203
|
+
for tool in (tools or [])
|
|
204
|
+
):
|
|
205
|
+
payload["include"] = self._merge_include_items(
|
|
206
|
+
payload.get("include"),
|
|
207
|
+
["no_inline_citations"],
|
|
208
|
+
)
|
|
209
|
+
return payload
|
|
210
|
+
|
|
211
|
+
async def create_response(
|
|
212
|
+
self,
|
|
213
|
+
*,
|
|
214
|
+
model: str,
|
|
215
|
+
messages: Iterable[dict[str, Any]] | None = None,
|
|
216
|
+
tools: list[dict[str, Any]] | None = None,
|
|
217
|
+
tool_choice: str | None = None,
|
|
218
|
+
previous_response_id: str | None = None,
|
|
219
|
+
input_items: list[dict[str, Any]] | None = None,
|
|
220
|
+
options: dict[str, Any] | None = None,
|
|
221
|
+
instructions: str | None = None,
|
|
222
|
+
provider: str | None = None,
|
|
223
|
+
api_base: str | None = None,
|
|
224
|
+
api_key: str | None = None,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
provider_name = provider or self.provider
|
|
227
|
+
base_url = self._base_url(provider_name, api_base)
|
|
228
|
+
payload = self.build_request_payload(
|
|
229
|
+
model=model,
|
|
230
|
+
messages=messages,
|
|
231
|
+
tools=tools,
|
|
232
|
+
tool_choice=tool_choice,
|
|
233
|
+
previous_response_id=previous_response_id,
|
|
234
|
+
input_items=input_items,
|
|
235
|
+
options=options,
|
|
236
|
+
instructions=instructions,
|
|
237
|
+
provider=provider_name,
|
|
238
|
+
)
|
|
239
|
+
log.info(
|
|
240
|
+
"api POST %s/responses provider=%s model=%s tools=%d",
|
|
241
|
+
base_url,
|
|
242
|
+
provider_name,
|
|
243
|
+
model,
|
|
244
|
+
len(tools or []),
|
|
245
|
+
)
|
|
246
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
|
|
247
|
+
response = await client.post(
|
|
248
|
+
f"{base_url}/responses",
|
|
249
|
+
headers=self._headers(provider_name, api_key),
|
|
250
|
+
json=payload,
|
|
251
|
+
)
|
|
252
|
+
response.raise_for_status()
|
|
253
|
+
return response.json()
|
|
254
|
+
|
|
255
|
+
async def ask(
|
|
256
|
+
self,
|
|
257
|
+
user_input: str,
|
|
258
|
+
previous_response_id: str | None = None,
|
|
259
|
+
*,
|
|
260
|
+
model: str | None = None,
|
|
261
|
+
system_prompt: str | None = None,
|
|
262
|
+
enabled_tools: list[str] | None = None,
|
|
263
|
+
provider: str | None = None,
|
|
264
|
+
api_base: str | None = None,
|
|
265
|
+
api_key: str | None = None,
|
|
266
|
+
max_tokens: int | None = None,
|
|
267
|
+
) -> tuple[str, str | None]:
|
|
268
|
+
del max_tokens
|
|
269
|
+
provider_name = provider or self.provider
|
|
270
|
+
final_model = model or self.model
|
|
271
|
+
prompt = self.system_prompt if system_prompt is None else system_prompt
|
|
272
|
+
messages: list[dict[str, str]] = []
|
|
273
|
+
if prompt:
|
|
274
|
+
messages.append({"role": "system", "content": prompt})
|
|
275
|
+
messages.append({"role": "user", "content": user_input})
|
|
276
|
+
tools = build_tools(
|
|
277
|
+
self.enabled_tools if enabled_tools is None else enabled_tools,
|
|
278
|
+
provider=provider_name,
|
|
279
|
+
)
|
|
280
|
+
result = await self.create_response(
|
|
281
|
+
model=final_model,
|
|
282
|
+
messages=messages,
|
|
283
|
+
tools=tools,
|
|
284
|
+
previous_response_id=previous_response_id,
|
|
285
|
+
provider=provider_name,
|
|
286
|
+
api_base=api_base,
|
|
287
|
+
api_key=api_key,
|
|
288
|
+
)
|
|
289
|
+
log.debug("Raw API response: %s", json.dumps(result, indent=2))
|
|
290
|
+
text = self._extract_text(result)
|
|
291
|
+
response_id = result.get("id")
|
|
292
|
+
return text, response_id
|
|
293
|
+
|
|
294
|
+
async def ask_messages(
|
|
295
|
+
self,
|
|
296
|
+
messages: Iterable[dict[str, Any]],
|
|
297
|
+
*,
|
|
298
|
+
model: str | None = None,
|
|
299
|
+
enabled_tools: list[str] | None = None,
|
|
300
|
+
built_tools: list[dict[str, Any]] | None = None,
|
|
301
|
+
provider: str | None = None,
|
|
302
|
+
api_base: str | None = None,
|
|
303
|
+
api_key: str | None = None,
|
|
304
|
+
max_tokens: int | None = None,
|
|
305
|
+
) -> tuple[str, str | None]:
|
|
306
|
+
provider_name = provider or self.provider
|
|
307
|
+
final_model = model or self.model
|
|
308
|
+
if built_tools is not None:
|
|
309
|
+
tools = built_tools
|
|
310
|
+
else:
|
|
311
|
+
tools = build_tools(
|
|
312
|
+
self.enabled_tools if enabled_tools is None else enabled_tools,
|
|
313
|
+
provider=provider_name,
|
|
314
|
+
)
|
|
315
|
+
options = {}
|
|
316
|
+
if max_tokens is not None:
|
|
317
|
+
options["max_output_tokens"] = max_tokens
|
|
318
|
+
result = await self.create_response(
|
|
319
|
+
model=final_model,
|
|
320
|
+
messages=messages,
|
|
321
|
+
tools=tools,
|
|
322
|
+
options=options or None,
|
|
323
|
+
provider=provider_name,
|
|
324
|
+
api_base=api_base,
|
|
325
|
+
api_key=api_key,
|
|
326
|
+
)
|
|
327
|
+
log.debug("Raw API response: %s", json.dumps(result, indent=2))
|
|
328
|
+
text = self._extract_text(result)
|
|
329
|
+
response_id = result.get("id")
|
|
330
|
+
return text, response_id
|
|
331
|
+
|
|
332
|
+
async def list_models(
|
|
333
|
+
self,
|
|
334
|
+
provider: str,
|
|
335
|
+
*,
|
|
336
|
+
api_base: str | None = None,
|
|
337
|
+
api_key: str | None = None,
|
|
338
|
+
) -> list[str]:
|
|
339
|
+
base_url = self._base_url(provider, api_base)
|
|
340
|
+
log.info("api GET %s/models provider=%s", base_url, provider)
|
|
341
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
|
|
342
|
+
response = await client.get(
|
|
343
|
+
f"{base_url}/models",
|
|
344
|
+
headers=self._headers(provider, api_key),
|
|
345
|
+
)
|
|
346
|
+
response.raise_for_status()
|
|
347
|
+
payload = response.json()
|
|
348
|
+
model_ids = [
|
|
349
|
+
str(item.get("id") or "").strip()
|
|
350
|
+
for item in payload.get("data", [])
|
|
351
|
+
if isinstance(item, dict)
|
|
352
|
+
]
|
|
353
|
+
filtered = sorted({model_id for model_id in model_ids if self._is_chat_model(provider, model_id)})
|
|
354
|
+
return filtered or sorted({model_id for model_id in model_ids if model_id})
|
|
355
|
+
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _extract_text(response: dict[str, Any]) -> str:
|
|
358
|
+
parts: list[str] = []
|
|
359
|
+
for item in response.get("output", []) or []:
|
|
360
|
+
if item.get("type") != "message":
|
|
361
|
+
continue
|
|
362
|
+
for content in item.get("content", []) or []:
|
|
363
|
+
if content.get("type") == "output_text":
|
|
364
|
+
text = str(content.get("text") or "")
|
|
365
|
+
if text:
|
|
366
|
+
parts.append(text)
|
|
367
|
+
if parts:
|
|
368
|
+
return "\n".join(parts).strip()
|
|
369
|
+
return str(response.get("output_text") or "").strip() or "(no response)"
|