codex-agent-framework 0.1.1__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.
- codex_agent/__init__.py +38 -0
- codex_agent/__main__.py +18 -0
- codex_agent/agent.py +1179 -0
- codex_agent/ai.py +494 -0
- codex_agent/builtin_commands.py +136 -0
- codex_agent/builtin_providers.py +42 -0
- codex_agent/builtin_tools.py +418 -0
- codex_agent/chat.py +139 -0
- codex_agent/command.py +19 -0
- codex_agent/event.py +136 -0
- codex_agent/get_text/__init__.py +20 -0
- codex_agent/get_text/default_gitignore +110 -0
- codex_agent/get_text/get_text.py +1240 -0
- codex_agent/get_text/simpler_get_text.py +184 -0
- codex_agent/get_webdriver.py +44 -0
- codex_agent/image.py +304 -0
- codex_agent/latex.py +165 -0
- codex_agent/memory.py +318 -0
- codex_agent/message.py +423 -0
- codex_agent/prompts/image_generation_system_prompt.txt +13 -0
- codex_agent/prompts/system_prompt.txt +88 -0
- codex_agent/provider.py +19 -0
- codex_agent/stream_utils.py +645 -0
- codex_agent/tool.py +235 -0
- codex_agent/utils.py +374 -0
- codex_agent/voice.py +353 -0
- codex_agent/worker.py +190 -0
- codex_agent_framework-0.1.1.dist-info/METADATA +361 -0
- codex_agent_framework-0.1.1.dist-info/RECORD +33 -0
- codex_agent_framework-0.1.1.dist-info/WHEEL +5 -0
- codex_agent_framework-0.1.1.dist-info/entry_points.txt +2 -0
- codex_agent_framework-0.1.1.dist-info/licenses/LICENSE +21 -0
- codex_agent_framework-0.1.1.dist-info/top_level.txt +1 -0
codex_agent/ai.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from .message import AssistantMessage, MessageChunk
|
|
3
|
+
from .utils import truncate
|
|
4
|
+
from modict import modict
|
|
5
|
+
import io, os
|
|
6
|
+
from openai import OpenAI, OpenAIError
|
|
7
|
+
from typing import Union, List
|
|
8
|
+
from pydub import AudioSegment
|
|
9
|
+
from .stream_utils import MappingStreamProcessor
|
|
10
|
+
from .event import (
|
|
11
|
+
AgentInterrupted,
|
|
12
|
+
ResponseContentDeltaEvent,
|
|
13
|
+
ResponseDoneEvent,
|
|
14
|
+
ResponseMessageDeltaEvent,
|
|
15
|
+
ResponseOutputItemEvent,
|
|
16
|
+
ResponseReasoningDeltaEvent,
|
|
17
|
+
ResponseStartEvent,
|
|
18
|
+
ResponseToolCallsDeltaEvent,
|
|
19
|
+
ServerToolCallEvent,
|
|
20
|
+
)
|
|
21
|
+
from codex_backend_sdk import (
|
|
22
|
+
CodexClient,
|
|
23
|
+
OutputItem,
|
|
24
|
+
ResponseAborted,
|
|
25
|
+
ReasoningDelta,
|
|
26
|
+
ResponseFailed,
|
|
27
|
+
TextDelta,
|
|
28
|
+
ToolCall,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
class ChunkStreamer(MappingStreamProcessor):
|
|
32
|
+
"""Stream processor for AI response chunks, handling content, reasoning, and tool calls.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
agent: The agent instance.
|
|
36
|
+
threaded: Whether to process streams in separate threads.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self,agent, threaded=True):
|
|
40
|
+
self.agent=agent
|
|
41
|
+
super().__init__(
|
|
42
|
+
defaults=dict(content='',reasoning='',tool_calls=None,output_items=None),
|
|
43
|
+
processors=dict(content=self.agent.content_processors),
|
|
44
|
+
type=MessageChunk,
|
|
45
|
+
threaded=threaded
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AIClientError(Exception):
|
|
50
|
+
"""Base exception for AI client errors."""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
class APIAuthenticationError(AIClientError):
|
|
54
|
+
"""Exception raised for OpenAI API authentication failures."""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
MAX_EMBEDDING_INPUT_TOKENS = 7500
|
|
59
|
+
|
|
60
|
+
class AIClient:
|
|
61
|
+
"""Client for Codex backend LLM calls and OpenAI voice/embedding calls."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, agent):
|
|
64
|
+
"""Initialize AI clients from agent config.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
agent: The agent instance containing configuration and API key.
|
|
68
|
+
"""
|
|
69
|
+
self.agent=agent
|
|
70
|
+
self._client = None
|
|
71
|
+
self._codex_client = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def client(self):
|
|
75
|
+
if self._client is None:
|
|
76
|
+
api_key=self.agent.config.get('openai_api_key') or os.getenv('OPENAI_API_KEY')
|
|
77
|
+
try:
|
|
78
|
+
self._client = OpenAI(api_key=api_key, timeout=180, max_retries=3)
|
|
79
|
+
if not self.api_key_is_valid():
|
|
80
|
+
self._client=None
|
|
81
|
+
raise APIAuthenticationError("Missing or invalid OpenAI API key. You can pass it when creating the agent with `agent=Agent(openai_api_key='your_api_key')` or via `agent.config.openai_api_key` or set the OPENAI_API_KEY environment variable. You can get an API key at https://platform.openai.com/account/api-keys.")
|
|
82
|
+
except OpenAIError:
|
|
83
|
+
self._client=None
|
|
84
|
+
raise APIAuthenticationError("Missing or invalid OpenAI API key. You can pass it when creating the agent with `agent=Agent(openai_api_key='your_api_key')` or via `agent.config.openai_api_key` or set the OPENAI_API_KEY environment variable. You can get an API key at https://platform.openai.com/account/api-keys.")
|
|
85
|
+
return self._client
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def codex_client(self):
|
|
89
|
+
"""Client used for LLM calls through the ChatGPT Codex backend."""
|
|
90
|
+
if self._codex_client is None:
|
|
91
|
+
model = self.agent.config.get('model') or "gpt-5.4"
|
|
92
|
+
self._codex_client = CodexClient(model=model).authenticate()
|
|
93
|
+
return self._codex_client
|
|
94
|
+
|
|
95
|
+
def abort(self):
|
|
96
|
+
if self._codex_client is not None:
|
|
97
|
+
self._codex_client.abort()
|
|
98
|
+
|
|
99
|
+
def api_key_is_valid(self):
|
|
100
|
+
"""
|
|
101
|
+
Verify that the API key is valid by making a minimal API call.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
bool: True if API key is valid, False otherwise
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
# Make a minimal API call to verify the key
|
|
108
|
+
self.client.models.list()
|
|
109
|
+
return True
|
|
110
|
+
except Exception as e:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
def text_to_audio(self, model=None, voice=None, input='', voice_instructions='', **kwargs):
|
|
114
|
+
"""
|
|
115
|
+
Generate speech audio from text using OpenAI TTS.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
model: TTS model identifier (default: "gpt-4o-mini-tts")
|
|
119
|
+
voice: OpenAI-style voice name (alloy, echo, fable, onyx, nova, shimmer)
|
|
120
|
+
input: Text to synthesize
|
|
121
|
+
voice_instructions: Tone hint
|
|
122
|
+
**kwargs: Additional parameters
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
BytesIO buffer containing MP3 audio
|
|
126
|
+
"""
|
|
127
|
+
# Build TTS input
|
|
128
|
+
params = modict(
|
|
129
|
+
model=model or "gpt-4o-mini-tts",
|
|
130
|
+
voice=voice or "nova",
|
|
131
|
+
instructions=voice_instructions or "You speak with a friendly and casual tone.",
|
|
132
|
+
input=input
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Override with any additional kwargs
|
|
136
|
+
params.update(kwargs)
|
|
137
|
+
|
|
138
|
+
# Call OpenAI API
|
|
139
|
+
try:
|
|
140
|
+
mp3_buffer = io.BytesIO()
|
|
141
|
+
|
|
142
|
+
response = self.client.audio.speech.create(**params)
|
|
143
|
+
|
|
144
|
+
for chunk in response.iter_bytes():
|
|
145
|
+
mp3_buffer.write(chunk)
|
|
146
|
+
|
|
147
|
+
mp3_buffer.seek(0)
|
|
148
|
+
|
|
149
|
+
audio = AudioSegment.from_file(mp3_buffer,format="mp3").set_channels(1)
|
|
150
|
+
|
|
151
|
+
return audio
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
raise AIClientError(f"TTS generation failed: {str(e)}")
|
|
155
|
+
|
|
156
|
+
def audio_to_text(
|
|
157
|
+
self,
|
|
158
|
+
source: Union[str, io.BytesIO, bytes],
|
|
159
|
+
model: str = "whisper-1",
|
|
160
|
+
language: str = None,
|
|
161
|
+
prompt: str = None,
|
|
162
|
+
response_format: str = "text",
|
|
163
|
+
temperature: float = 0.0
|
|
164
|
+
) -> str:
|
|
165
|
+
"""
|
|
166
|
+
Convert audio to text using OpenAI's Whisper model.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
source: Audio data as file_path string, BytesIO object or bytes
|
|
170
|
+
model: Model to use (default: "whisper-1")
|
|
171
|
+
language: ISO-639-1 language code (e.g., "en", "fr"). If None, language is auto-detected.
|
|
172
|
+
prompt: Optional text to guide the model's style
|
|
173
|
+
response_format: Format of output ("text", "json", "verbose_json", "srt", "vtt")
|
|
174
|
+
temperature: Sampling temperature between 0 and 1 (default: 0.0)
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Transcribed text as a string
|
|
178
|
+
"""
|
|
179
|
+
if isinstance(source, str) and os.path.isfile(source):
|
|
180
|
+
with open(source, "rb") as f:
|
|
181
|
+
audio = io.BytesIO(f.read())
|
|
182
|
+
elif isinstance(source, bytes):
|
|
183
|
+
audio = io.BytesIO(source)
|
|
184
|
+
elif isinstance(source, io.BytesIO):
|
|
185
|
+
audio = source
|
|
186
|
+
else:
|
|
187
|
+
raise NotImplementedError(f"Unsupported audio source: {source}")
|
|
188
|
+
|
|
189
|
+
# Ensure the BytesIO object is at the start
|
|
190
|
+
audio.seek(0)
|
|
191
|
+
|
|
192
|
+
# Prepare the file for upload
|
|
193
|
+
audio.name = "audio.mp3" # Add a name attribute
|
|
194
|
+
|
|
195
|
+
# Prepare transcription parameters
|
|
196
|
+
transcription_params = {
|
|
197
|
+
"model": model,
|
|
198
|
+
"file": audio,
|
|
199
|
+
"response_format": response_format,
|
|
200
|
+
"temperature": temperature
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Add optional parameters if provided
|
|
204
|
+
if language:
|
|
205
|
+
transcription_params["language"] = language
|
|
206
|
+
if prompt:
|
|
207
|
+
transcription_params["prompt"] = prompt
|
|
208
|
+
|
|
209
|
+
# Call OpenAI transcription API (retries handled by client)
|
|
210
|
+
try:
|
|
211
|
+
transcript = self.client.audio.transcriptions.create(**transcription_params)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
raise AIClientError(f"Failed to transcribe audio: {str(e)}")
|
|
214
|
+
|
|
215
|
+
# Extract text from response
|
|
216
|
+
if response_format == "text":
|
|
217
|
+
return transcript
|
|
218
|
+
elif response_format in ["json", "verbose_json"]:
|
|
219
|
+
return transcript.text
|
|
220
|
+
else:
|
|
221
|
+
# For srt and vtt formats, return as-is
|
|
222
|
+
return transcript
|
|
223
|
+
|
|
224
|
+
def _stream_chunks(self, params):
|
|
225
|
+
params['tools'] = [
|
|
226
|
+
tool.to_response_tool() if hasattr(tool, "to_response_tool") else tool
|
|
227
|
+
for tool in params.get('tools') or []
|
|
228
|
+
] or None
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
stream_params = self._to_codex_stream_params(params)
|
|
232
|
+
response = self.codex_client.stream(
|
|
233
|
+
stream_params.pop('user_message'),
|
|
234
|
+
**stream_params
|
|
235
|
+
)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
import traceback
|
|
238
|
+
import sys
|
|
239
|
+
print("\n[ERROR in ai.run setup]:", file=sys.stderr)
|
|
240
|
+
traceback.print_exc(file=sys.stderr)
|
|
241
|
+
yield MessageChunk(content=f"Hmmm, sorry! There was an error while calling the Codex backend client. Here is the error message I got:\n\n ```\n{str(e)}\n```")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
tool_call_index = 0
|
|
246
|
+
for event in response:
|
|
247
|
+
msg_chunk = self._codex_event_to_message_chunk(event, tool_call_index)
|
|
248
|
+
if msg_chunk and msg_chunk.get("tool_calls"):
|
|
249
|
+
tool_call_index += 1
|
|
250
|
+
if msg_chunk:
|
|
251
|
+
yield msg_chunk
|
|
252
|
+
except ResponseAborted:
|
|
253
|
+
raise AgentInterrupted()
|
|
254
|
+
except Exception as e:
|
|
255
|
+
yield MessageChunk(content=f"Hmmm, sorry! There was an error while reading the Codex backend stream. Here is the error message I got:\n\n ```\n{str(e)}\n```")
|
|
256
|
+
|
|
257
|
+
def _split_codex_messages(self, messages):
|
|
258
|
+
instructions = None
|
|
259
|
+
history = []
|
|
260
|
+
for msg in messages:
|
|
261
|
+
role = msg.get("role")
|
|
262
|
+
content = msg.get("content", "")
|
|
263
|
+
if role == "system" and instructions is None and isinstance(content, str):
|
|
264
|
+
instructions = content
|
|
265
|
+
continue
|
|
266
|
+
response_format = msg.to_response_format()
|
|
267
|
+
if response_format is None:
|
|
268
|
+
continue
|
|
269
|
+
if isinstance(response_format, list):
|
|
270
|
+
history.extend(response_format)
|
|
271
|
+
else:
|
|
272
|
+
history.append(response_format)
|
|
273
|
+
return instructions or "", history
|
|
274
|
+
|
|
275
|
+
def _to_codex_stream_params(self, params):
|
|
276
|
+
instructions, history = self._split_codex_messages(params.get("messages", []))
|
|
277
|
+
tools = params.get("tools") or []
|
|
278
|
+
reasoning = params.get("reasoning_effort")
|
|
279
|
+
model = params.get("model") or "gpt-5.4"
|
|
280
|
+
verbosity = params.get("verbosity")
|
|
281
|
+
return {
|
|
282
|
+
"user_message": None,
|
|
283
|
+
"conversation_history": history,
|
|
284
|
+
"instructions": instructions,
|
|
285
|
+
"model": model,
|
|
286
|
+
"verbosity": verbosity,
|
|
287
|
+
"tools": tools or None,
|
|
288
|
+
"tool_choice": "auto" if tools else "none",
|
|
289
|
+
"reasoning": reasoning,
|
|
290
|
+
"reasoning_summary": "auto" if reasoning else None,
|
|
291
|
+
"include_reasoning": bool(reasoning),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
def _codex_event_to_message_chunk(self, event, tool_call_index=0):
|
|
295
|
+
if isinstance(event, TextDelta):
|
|
296
|
+
return MessageChunk(content=event.text)
|
|
297
|
+
if isinstance(event, ReasoningDelta):
|
|
298
|
+
return MessageChunk(reasoning=event.text)
|
|
299
|
+
if isinstance(event, ToolCall):
|
|
300
|
+
tool_call = modict({
|
|
301
|
+
"index": tool_call_index,
|
|
302
|
+
"type": "function_call",
|
|
303
|
+
"call_id": event.call_id,
|
|
304
|
+
"name": event.name,
|
|
305
|
+
"arguments": event.arguments,
|
|
306
|
+
})
|
|
307
|
+
return MessageChunk(tool_calls=[tool_call])
|
|
308
|
+
if isinstance(event, OutputItem):
|
|
309
|
+
item = modict(event.raw)
|
|
310
|
+
if item.get("type") == "custom_tool_call":
|
|
311
|
+
tool_call = modict({
|
|
312
|
+
"index": tool_call_index,
|
|
313
|
+
"type": "custom_tool_call",
|
|
314
|
+
"call_id": item.get("call_id", ""),
|
|
315
|
+
"name": item.get("name", ""),
|
|
316
|
+
"input": item.get("input", ""),
|
|
317
|
+
})
|
|
318
|
+
return MessageChunk(tool_calls=[tool_call])
|
|
319
|
+
return MessageChunk(output_items=[item])
|
|
320
|
+
if isinstance(event, ResponseFailed):
|
|
321
|
+
return MessageChunk(content=f"Hmmm, sorry! The Codex backend returned an error:\n\n```\n[{event.code}] {event.message}\n```")
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
def run(self, **params):
|
|
325
|
+
stream = ChunkStreamer(self.agent)(self._stream_chunks(params))
|
|
326
|
+
message = AssistantMessage(name=self.agent.config.get('name','assistant'))
|
|
327
|
+
content = ''
|
|
328
|
+
reasoning = ''
|
|
329
|
+
|
|
330
|
+
self.agent.emit(ResponseStartEvent, message=message)
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
for chunk in stream:
|
|
334
|
+
self.agent.check_interrupt()
|
|
335
|
+
message.add_chunk(chunk)
|
|
336
|
+
|
|
337
|
+
if chunk.get('content'):
|
|
338
|
+
content += chunk.content
|
|
339
|
+
self.agent.emit(
|
|
340
|
+
ResponseContentDeltaEvent,
|
|
341
|
+
delta=chunk.content,
|
|
342
|
+
content=content,
|
|
343
|
+
chunk=chunk,
|
|
344
|
+
message=message,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if chunk.get('reasoning'):
|
|
348
|
+
reasoning += chunk.reasoning
|
|
349
|
+
self.agent.emit(
|
|
350
|
+
ResponseReasoningDeltaEvent,
|
|
351
|
+
delta=chunk.reasoning,
|
|
352
|
+
reasoning=reasoning,
|
|
353
|
+
chunk=chunk,
|
|
354
|
+
message=message,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if chunk.get('tool_calls'):
|
|
358
|
+
self.agent.emit(
|
|
359
|
+
ResponseToolCallsDeltaEvent,
|
|
360
|
+
tool_calls=chunk.tool_calls,
|
|
361
|
+
chunk=chunk,
|
|
362
|
+
message=message,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
for item in chunk.get('output_items') or []:
|
|
366
|
+
self.agent.emit(
|
|
367
|
+
ResponseOutputItemEvent,
|
|
368
|
+
item=item,
|
|
369
|
+
chunk=chunk,
|
|
370
|
+
message=message,
|
|
371
|
+
)
|
|
372
|
+
server_tool = self.agent.server_tool_for_item(item)
|
|
373
|
+
if server_tool:
|
|
374
|
+
self.agent.emit(
|
|
375
|
+
ServerToolCallEvent,
|
|
376
|
+
item=item,
|
|
377
|
+
tool=server_tool,
|
|
378
|
+
name=server_tool.type,
|
|
379
|
+
call_id=item.get("call_id") or item.get("id") or server_tool.type,
|
|
380
|
+
status=item.get("status", ""),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
self.agent.emit(ResponseMessageDeltaEvent, chunk=chunk, message=message)
|
|
384
|
+
except KeyboardInterrupt:
|
|
385
|
+
self.agent.interrupt("keyboard_interrupt")
|
|
386
|
+
raise AgentInterrupted()
|
|
387
|
+
|
|
388
|
+
self.agent.emit(ResponseDoneEvent, message=message)
|
|
389
|
+
return message
|
|
390
|
+
|
|
391
|
+
def _normalize(self, vect, precision=5):
|
|
392
|
+
"""Normalize a vector and round to specified precision."""
|
|
393
|
+
inv_norm = 1.0 / np.linalg.norm(vect, ord=2)
|
|
394
|
+
return [round(x_i * inv_norm, precision) for x_i in vect]
|
|
395
|
+
|
|
396
|
+
def truncate_embedding_input(self, content):
|
|
397
|
+
return truncate(content, max_tokens=MAX_EMBEDDING_INPUT_TOKENS)
|
|
398
|
+
|
|
399
|
+
def embed_content(self, content: str, precision=5, dimensions=128, model="text-embedding-3-small"):
|
|
400
|
+
"""
|
|
401
|
+
Embed a text string using OpenAI embeddings.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
content: Text string to embed
|
|
405
|
+
precision: Number of decimal places for rounding (default: 5)
|
|
406
|
+
dimensions: Embedding dimensions (default: 128)
|
|
407
|
+
model: OpenAI embedding model to use (default: text-embedding-3-small)
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
A normalized embedding vector
|
|
411
|
+
"""
|
|
412
|
+
if not isinstance(content, str):
|
|
413
|
+
raise ValueError(f"Content must be a string, got {type(content)}")
|
|
414
|
+
content = self.truncate_embedding_input(content)
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
response = self.client.embeddings.create(
|
|
418
|
+
model=model,
|
|
419
|
+
input=content,
|
|
420
|
+
dimensions=dimensions
|
|
421
|
+
)
|
|
422
|
+
embedding = response.data[0].embedding
|
|
423
|
+
except Exception as e:
|
|
424
|
+
raise AIClientError(f"Failed to generate embedding: {str(e)}")
|
|
425
|
+
|
|
426
|
+
return self._normalize(embedding, precision)
|
|
427
|
+
|
|
428
|
+
def embed_contents(self, contents: List[str], precision=5, dimensions=128, model="text-embedding-3-small"):
|
|
429
|
+
"""
|
|
430
|
+
Embed multiple text strings using OpenAI embeddings.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
contents: List of text strings to embed
|
|
434
|
+
precision: Number of decimal places for rounding (default: 5)
|
|
435
|
+
dimensions: Embedding dimensions (default: 128)
|
|
436
|
+
model: OpenAI embedding model to use (default: text-embedding-3-small)
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
List of normalized embedding vectors
|
|
440
|
+
|
|
441
|
+
Note:
|
|
442
|
+
OpenAI API handles batching automatically for better performance.
|
|
443
|
+
"""
|
|
444
|
+
if not all(isinstance(content, str) for content in contents):
|
|
445
|
+
raise ValueError("All contents must be strings")
|
|
446
|
+
contents = [self.truncate_embedding_input(content) for content in contents]
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
response = self.client.embeddings.create(
|
|
450
|
+
model=model,
|
|
451
|
+
input=contents,
|
|
452
|
+
dimensions=dimensions
|
|
453
|
+
)
|
|
454
|
+
embeddings = [self._normalize(item.embedding, precision) for item in response.data]
|
|
455
|
+
except Exception as e:
|
|
456
|
+
raise AIClientError(f"Failed to generate embeddings: {str(e)}")
|
|
457
|
+
|
|
458
|
+
return embeddings
|
|
459
|
+
|
|
460
|
+
def similarity(self, emb1, emb2):
|
|
461
|
+
"""
|
|
462
|
+
Calculate shifted positive cosine similarity between two embeddings.
|
|
463
|
+
Returns a score between 0 and 1.
|
|
464
|
+
"""
|
|
465
|
+
return 0.5 + 0.5 * np.dot(np.array(emb1), np.array(emb2))
|
|
466
|
+
|
|
467
|
+
def embed_message(self, msg, precision=5, dimensions=128, model="text-embedding-3-small"):
|
|
468
|
+
"""
|
|
469
|
+
Embed a Message object and assign the result to msg.embedding.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
msg: Message object with text content
|
|
473
|
+
precision: Number of decimal places for rounding (default: 5)
|
|
474
|
+
dimensions: Embedding dimensions (default: 128)
|
|
475
|
+
model: OpenAI embedding model to use (default: text-embedding-3-small)
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
The embedding vector (also assigned to msg.embedding)
|
|
479
|
+
|
|
480
|
+
Note:
|
|
481
|
+
Only text content is supported.
|
|
482
|
+
"""
|
|
483
|
+
if hasattr(msg, 'content') and isinstance(msg.content, str):
|
|
484
|
+
content = msg.content
|
|
485
|
+
else:
|
|
486
|
+
raise ValueError("Message must have text content")
|
|
487
|
+
|
|
488
|
+
# Generate embedding
|
|
489
|
+
embedding = self.embed_content(content, precision, dimensions, model)
|
|
490
|
+
|
|
491
|
+
# Assign to msg.embedding
|
|
492
|
+
msg.embedding = embedding
|
|
493
|
+
|
|
494
|
+
return embedding
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Built-in slash commands."""
|
|
2
|
+
|
|
3
|
+
from .command import command
|
|
4
|
+
from .tool import get_agent
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
MODEL_CONFIG_KEYS = (
|
|
8
|
+
"model",
|
|
9
|
+
"reasoning_effort",
|
|
10
|
+
"verbosity",
|
|
11
|
+
"input_token_limit",
|
|
12
|
+
"max_input_tokens",
|
|
13
|
+
"auto_compact",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_config_value(value):
|
|
18
|
+
if isinstance(value, bool):
|
|
19
|
+
return value
|
|
20
|
+
if not isinstance(value, str):
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
lowered = value.lower()
|
|
24
|
+
if lowered in ("true", "yes", "on", "1"):
|
|
25
|
+
return True
|
|
26
|
+
if lowered in ("false", "no", "off", "0"):
|
|
27
|
+
return False
|
|
28
|
+
if value.isdigit():
|
|
29
|
+
return int(value)
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_model_config(agent):
|
|
34
|
+
return "\n".join(f"{key}: {agent.config.get(key)!r}" for key in MODEL_CONFIG_KEYS)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def update_model_config(**values):
|
|
38
|
+
agent = get_agent()
|
|
39
|
+
updates = {key: parse_config_value(value) for key, value in values.items()}
|
|
40
|
+
unknown = sorted(key for key in updates if key not in MODEL_CONFIG_KEYS)
|
|
41
|
+
if unknown:
|
|
42
|
+
raise ValueError(f"Unknown model config keys: {', '.join(unknown)}")
|
|
43
|
+
agent.update_config(updates)
|
|
44
|
+
changed = ", ".join(f"{key}={agent.config.get(key)!r}" for key in updates)
|
|
45
|
+
return f"Updated config: {changed}" if changed else format_model_config(agent)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@command
|
|
49
|
+
def help():
|
|
50
|
+
agent = get_agent()
|
|
51
|
+
names = ", ".join(f"/{name}" for name in sorted(agent.commands))
|
|
52
|
+
return f"Available commands: {names}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@command
|
|
56
|
+
def compact():
|
|
57
|
+
agent = get_agent()
|
|
58
|
+
message = agent.session.compact(
|
|
59
|
+
model=agent.config.model,
|
|
60
|
+
instructions=agent.get_system_message().content,
|
|
61
|
+
)
|
|
62
|
+
if message is None:
|
|
63
|
+
return "Nothing to compact."
|
|
64
|
+
return f"Compacted {message.compacted_message_count} messages."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@command
|
|
68
|
+
def sessions():
|
|
69
|
+
agent = get_agent()
|
|
70
|
+
session_ids = agent.get_sessions()
|
|
71
|
+
if not session_ids:
|
|
72
|
+
return "No saved sessions."
|
|
73
|
+
lines = []
|
|
74
|
+
for session_id in session_ids:
|
|
75
|
+
prefix = "* " if session_id == agent.current_session_id else " "
|
|
76
|
+
lines.append(f"{prefix}{session_id}")
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@command
|
|
81
|
+
def new_session():
|
|
82
|
+
agent = get_agent()
|
|
83
|
+
session = agent.start_new_session()
|
|
84
|
+
return f"Created session: {session.session_id}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@command
|
|
88
|
+
def load_session(session_id="latest"):
|
|
89
|
+
agent = get_agent()
|
|
90
|
+
session = agent.load_session(session_id)
|
|
91
|
+
return f"Loaded session: {session.session_id}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@command
|
|
95
|
+
def delete_session(session_id):
|
|
96
|
+
agent = get_agent()
|
|
97
|
+
deleted_id = agent.delete_session(session_id)
|
|
98
|
+
return f"Deleted session: {deleted_id}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@command
|
|
102
|
+
def next_session():
|
|
103
|
+
session = get_agent().navigate_session("next")
|
|
104
|
+
return f"Loaded session: {session.session_id}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@command
|
|
108
|
+
def previous_session():
|
|
109
|
+
session = get_agent().navigate_session("previous")
|
|
110
|
+
return f"Loaded session: {session.session_id}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@command
|
|
114
|
+
def config(**values):
|
|
115
|
+
return update_model_config(**values)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@command
|
|
119
|
+
def model(value=None):
|
|
120
|
+
if value is None:
|
|
121
|
+
return f"model: {get_agent().config.model!r}"
|
|
122
|
+
return update_model_config(model=value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@command
|
|
126
|
+
def reasoning(value=None):
|
|
127
|
+
if value is None:
|
|
128
|
+
return f"reasoning_effort: {get_agent().config.reasoning_effort!r}"
|
|
129
|
+
return update_model_config(reasoning_effort=value)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@command
|
|
133
|
+
def verbosity(value=None):
|
|
134
|
+
if value is None:
|
|
135
|
+
return f"verbosity: {get_agent().config.verbosity!r}"
|
|
136
|
+
return update_model_config(verbosity=value)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Built-in ephemeral context providers."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from .memory import format_memory_results_xml
|
|
7
|
+
from .provider import provider
|
|
8
|
+
from .tool import get_agent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@provider
|
|
12
|
+
def date_and_time():
|
|
13
|
+
now = datetime.now().astimezone()
|
|
14
|
+
return f"Current date and time: {now.strftime('%Y-%m-%d %H:%M:%S %Z%z')}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@provider
|
|
18
|
+
def cwd():
|
|
19
|
+
return f"Current working directory: {os.getcwd()}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@provider
|
|
23
|
+
def retrieved_memory():
|
|
24
|
+
agent = get_agent()
|
|
25
|
+
if not agent.memory.messages:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
context = [
|
|
29
|
+
msg for msg in agent.session.context_messages()
|
|
30
|
+
if agent.is_message_visible_in_context(msg)
|
|
31
|
+
]
|
|
32
|
+
if not context:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
results = agent.memory.retrieve(
|
|
36
|
+
context,
|
|
37
|
+
max_tokens=agent.config.max_memory_tokens,
|
|
38
|
+
until=agent.memory_retrieval_until_timestamp(),
|
|
39
|
+
)
|
|
40
|
+
if not results:
|
|
41
|
+
return None
|
|
42
|
+
return format_memory_results_xml(results, root="retrieved_memories")
|