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/voice.py ADDED
@@ -0,0 +1,353 @@
1
+ from .utils import utf8_safe_tokenize
2
+ import time
3
+ import re
4
+ from .stream_utils import Task, Streamer, TokenStreamer, fenced
5
+ from .event import AudioPlaybackEvent
6
+ from modict import modict
7
+ from pydub import AudioSegment
8
+ from pydub.playback import _play_with_simpleaudio, _play_with_pyaudio
9
+
10
+ def silent_play(audio):
11
+ """Play audio using the best available backend without console output.
12
+
13
+ Tries backends in order: simpleaudio, pyaudio, ffplay.
14
+
15
+ Args:
16
+ audio: AudioSegment object to play.
17
+ """
18
+ # Sur Windows, ffplay rare, simpleaudio/pyaudio par défaut
19
+ # Sur Linux/Mac, ffplay fréquent
20
+ try:
21
+ # Essaye simpleaudio
22
+ return _play_with_simpleaudio(audio)
23
+ except Exception:
24
+ pass
25
+ try:
26
+ # Essaye pyaudio
27
+ return _play_with_pyaudio(audio)
28
+ except Exception:
29
+ pass
30
+ try:
31
+ # Utilise ffplay en mode silencieux
32
+ import subprocess
33
+ import tempfile
34
+ with tempfile.NamedTemporaryFile("w+b", suffix=".wav", delete=True) as f:
35
+ audio.export(f.name, "wav")
36
+ subprocess.call(
37
+ [
38
+ "ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", f.name
39
+ ],
40
+ stdout=subprocess.DEVNULL,
41
+ stderr=subprocess.DEVNULL
42
+ )
43
+ except Exception:
44
+ print("Aucun backend audio fonctionnel trouvé.")
45
+
46
+ class PlayAudio(Task):
47
+ """Task for playing audio asynchronously.
48
+
49
+ Args:
50
+ agent: The agent instance.
51
+ args: Positional arguments for the task.
52
+ kwargs: Keyword arguments for the task.
53
+ """
54
+
55
+ def __init__(self,agent,args=None,kwargs=None):
56
+ super().__init__(args=args,kwargs=kwargs)
57
+ self.agent=agent
58
+
59
+ def target(self,audio):
60
+ """Play audio through event handlers, falling back to local playback.
61
+
62
+ Args:
63
+ audio: AudioSegment to play.
64
+ """
65
+ if audio is not None:
66
+ if self.agent.events.has_handlers(AudioPlaybackEvent):
67
+ self.agent.emit(AudioPlaybackEvent, audio=audio)
68
+ else:
69
+ silent_play(audio)
70
+
71
+ class MuteTagProcessor(Streamer):
72
+ """Stream processor that wraps code blocks, LaTeX formulas, and links in MUTE tags.
73
+
74
+ These tagged sections will be skipped during text-to-speech conversion.
75
+
76
+ Args:
77
+ agent: The agent instance.
78
+ """
79
+
80
+ def __init__(self,agent):
81
+ super().__init__()
82
+ self.agent=agent
83
+ self.token_streamer = TokenStreamer([
84
+ #Intentionally muted blocs -> aggregate in buffer and pass through
85
+ (fenced('<MUTE>','</MUTE>',flags=re.DOTALL|re.MULTILINE), lambda m: m.group()),
86
+ # Mute markdown code blocs
87
+ (fenced('```','```',flags=re.DOTALL|re.MULTILINE), lambda m: f'<MUTE>{m.group()}</MUTE>'),
88
+ # Mute LaTeX formulas
89
+ (fenced(r'\$\$',r'\$\$',flags=re.DOTALL|re.MULTILINE), lambda m: f'<MUTE>{m.group()}</MUTE>'),
90
+ (fenced(r'\\\[',r'\\\]',flags=re.DOTALL|re.MULTILINE), lambda m: f'<MUTE>{m.group()}</MUTE>'),
91
+ (fenced(r'\\\(',r'\\\)',flags=re.DOTALL|re.MULTILINE), lambda m: f'<MUTE>{m.group()}</MUTE>'),
92
+ (r'\$[^$\n]+?\$', lambda m: f'<MUTE>{m.group()}</MUTE>'),
93
+ # Mute links
94
+ (r'https?://\S+', lambda m: f'<MUTE>{m.group()}</MUTE>'),
95
+ ], threaded=False)
96
+
97
+ def stream_processor(self, stream):
98
+ """Process stream and add MUTE tags around code/formulas/links."""
99
+ return self.token_streamer.process(stream)
100
+
101
+ class MuteAnalyzer(Streamer):
102
+ """Splits token stream into muted and non-muted parts.
103
+
104
+ Args:
105
+ agent: The agent instance.
106
+ """
107
+
108
+ def __init__(self,agent):
109
+ super().__init__()
110
+ self.agent=agent
111
+
112
+ def stream_processor(self, stream):
113
+ """Split stream into tuples of (mode, text) where mode is 'mute' or 'speak'."""
114
+ for token in stream:
115
+ if token.startswith('<MUTE>') and token.endswith('</MUTE>'):
116
+ yield ('mute', token[6:-7]) # Strip <MUTE>...</MUTE> tags
117
+ else:
118
+ yield ('speak',token)
119
+
120
+ class LineAgregator(Streamer):
121
+ """Aggregates tokens into lines for voice processing.
122
+
123
+ Buffers tokens until newline characters are encountered or token count
124
+ exceeds threshold, then yields complete lines with their mode (mute/speak).
125
+
126
+ Args:
127
+ agent: The agent instance.
128
+ """
129
+
130
+ def __init__(self,agent):
131
+ super().__init__()
132
+ self.agent=agent
133
+
134
+ def stream_processor(self,stream):
135
+ """Aggregate tokens into lines based on newlines and token count.
136
+
137
+ Args:
138
+ stream: Input stream of (mode, token) tuples.
139
+
140
+ Yields:
141
+ Tuples of (mode, line_text) for each aggregated line.
142
+ """
143
+ lines=""
144
+ token_count=0
145
+ last_mode=None
146
+ for mode, token in stream:
147
+
148
+ if mode!=last_mode:
149
+ if lines and last_mode is not None:
150
+ yield (last_mode,lines)
151
+ lines=""
152
+ token_count=0
153
+ last_mode=mode
154
+
155
+ token_count+=1
156
+ while '\n' in token: #in case a token contains several occurences of '\n'
157
+ parts=token.split('\n')
158
+ lines+=parts[0]+'\n'
159
+ if token_count>50:
160
+ yield (last_mode,lines)
161
+ lines=""
162
+ token_count=0
163
+ token='\n'.join(parts[1:])
164
+ else:
165
+ lines+=token
166
+ if lines and last_mode is not None:
167
+ yield (last_mode,lines)
168
+
169
+ class LineToAudio(Streamer):
170
+ """Converts text lines to audio segments using text-to-speech.
171
+
172
+ Args:
173
+ agent: The agent instance with AI client for TTS.
174
+ """
175
+
176
+ def __init__(self,agent):
177
+ super().__init__()
178
+ self.agent=agent
179
+
180
+ def text_to_audio(self,text):
181
+ """Convert text to audio using configured TTS model.
182
+
183
+ Args:
184
+ text: Text string to convert to speech.
185
+
186
+ Returns:
187
+ AudioSegment object or None if text is empty.
188
+ """
189
+ # Create MP3 audio
190
+
191
+ if text.strip():
192
+
193
+ params=modict(
194
+ model=self.agent.config.get('voice_model','gpt-4o-mini-tts'),
195
+ voice=self.agent.config.get('voice','nova'),
196
+ instructions=self.agent.config.get('voice_instructions','You speak with a friendly and casual tone.'),
197
+ input=text
198
+ )
199
+
200
+ audio = self.agent.ai.text_to_audio(**params)
201
+
202
+ return audio
203
+ else:
204
+ return None
205
+
206
+ def stream_processor(self,stream):
207
+ """Process stream of lines, converting non-muted text to audio.
208
+
209
+ Args:
210
+ stream: Stream of (mode, line) tuples.
211
+
212
+ Yields:
213
+ Tuples of (line, audio) where audio is AudioSegment or None for muted lines.
214
+ """
215
+ for mode,line in stream:
216
+ if not line:
217
+ continue
218
+
219
+ if mode=='mute':
220
+ audio=None
221
+ else:
222
+ audio=self.text_to_audio(line)
223
+
224
+ yield (line,audio)
225
+
226
+ class AudioPlayer(Streamer):
227
+ """Plays audio while streaming tokens at synchronized pace.
228
+
229
+ Plays audio segments in background while yielding tokens at a rate
230
+ synchronized with the audio playback duration.
231
+
232
+ Args:
233
+ agent: The agent instance.
234
+ """
235
+
236
+ def __init__(self,agent):
237
+ super().__init__()
238
+ self.agent=agent
239
+
240
+ def stream_processor(self,stream):
241
+ """Play audio and yield tokens synchronized with playback.
242
+
243
+ Args:
244
+ stream: Stream of (line, audio) tuples.
245
+
246
+ Yields:
247
+ Individual tokens with timing synchronized to audio playback.
248
+ """
249
+ for line,audio in stream:
250
+ tokenized=utf8_safe_tokenize(line)
251
+ if audio is not None:
252
+ sleep_time_per_token = 0.95*(audio.duration_seconds / len(tokenized)) # Durée ajustée par token
253
+ task=PlayAudio(self.agent,args=(audio,))
254
+ task.start()
255
+ for token in tokenized:
256
+ yield token
257
+ time.sleep(sleep_time_per_token)
258
+ task.join()
259
+ else:
260
+ for token in tokenized:
261
+ yield token
262
+ time.sleep(0.02)
263
+
264
+ class Throttler(Streamer):
265
+ """Buffers and batches tokens to control output rate.
266
+
267
+ Accumulates tokens in a buffer and yields them in batches to prevent
268
+ overwhelming downstream consumers.
269
+
270
+ Args:
271
+ agent: The agent instance with buffer size configuration.
272
+ """
273
+
274
+ def __init__(self,agent):
275
+ super().__init__()
276
+ self.agent=agent
277
+
278
+ def stream_processor(self,stream):
279
+ """Buffer and batch tokens before yielding.
280
+
281
+ Args:
282
+ stream: Input token stream.
283
+
284
+ Yields:
285
+ Batched token strings.
286
+ """
287
+ buffer=''
288
+ buffer_size=self.agent.config.get('voice_buffer_size',3) # Number of tokens to buffer before yielding the result
289
+ for i,token in enumerate(stream):
290
+ buffer+=token
291
+ if i%buffer_size==0:
292
+ yield buffer
293
+ buffer=''
294
+ if buffer:
295
+ yield buffer
296
+
297
+
298
+
299
+ class VoiceProcessor(Streamer):
300
+ """Orchestrates text-to-speech processing for token streams.
301
+
302
+ Coordinates multiple processing stages:
303
+ 1. Tags code blocks, formulas, and links as muted
304
+ 2. Analyzes and splits into muted/spoken sections
305
+ 3. Aggregates tokens into lines
306
+ 4. Converts spoken lines to audio
307
+ 5. Plays audio synchronized with token output
308
+ 6. Throttles output for controlled streaming
309
+
310
+ Args:
311
+ agent: The agent instance with voice configuration.
312
+ """
313
+
314
+ def __init__(self,agent):
315
+ super().__init__()
316
+ self.agent=agent
317
+ self.mute_tag_processor=MuteTagProcessor(self.agent)
318
+ self.line_splitter=LineAgregator(self.agent)
319
+ self.line_to_audio=LineToAudio(self.agent)
320
+ self.speech_processor=AudioPlayer(self.agent)
321
+ self.mute_analyzer=MuteAnalyzer(self.agent)
322
+ self.throttler=Throttler(self.agent)
323
+
324
+ def __call__(self,stream):
325
+ """Process stream with voice if enabled, otherwise pass through.
326
+
327
+ Args:
328
+ stream: Input token stream.
329
+
330
+ Returns:
331
+ Processed token stream (with or without voice).
332
+ """
333
+ if self.agent.config.get('voice_enabled',False):
334
+ return self.process(stream)
335
+ else:
336
+ return stream
337
+
338
+ def stream_processor(self, stream):
339
+ """Chain all voice processing stages together.
340
+
341
+ Args:
342
+ stream: Input token stream.
343
+
344
+ Returns:
345
+ Token stream synchronized with speech output.
346
+ """
347
+ tagged_stream = self.mute_tag_processor.process(stream)
348
+ analyzed_stream= self.mute_analyzer.process(tagged_stream)
349
+ marked_lines = self.line_splitter.process(analyzed_stream)
350
+ lines_with_playback = self.line_to_audio.process(marked_lines)
351
+ synced_stream=self.speech_processor.process(lines_with_playback)
352
+ throttled_stream=self.throttler.process(synced_stream)
353
+ return throttled_stream
codex_agent/worker.py ADDED
@@ -0,0 +1,190 @@
1
+ from modict import modict
2
+
3
+ from codex_backend_sdk import CodexClient, OutputItem, ReasoningDelta, ResponseAborted, ResponseFailed, TextDelta, ToolCall
4
+ from .tool import Tool
5
+
6
+
7
+ class Worker:
8
+ """One-shot backend worker base class.
9
+
10
+ Subclasses specialize behavior by overriding class fields such as
11
+ ``system_prompt``, ``tools``, and model defaults.
12
+ """
13
+
14
+ system_prompt = "Complete the requested task directly and return only the useful result."
15
+ model = "gpt-5.4"
16
+ reasoning_effort = "medium"
17
+ verbosity = "medium"
18
+ tools = []
19
+ tool_choice = None
20
+ output_schema = None
21
+ resolve_tools = True
22
+ name = "worker"
23
+ max_retries = 3
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ client=None,
29
+ system_prompt=None,
30
+ model=None,
31
+ reasoning_effort=None,
32
+ verbosity=None,
33
+ tools=None,
34
+ tool_choice=None,
35
+ output_schema=None,
36
+ resolve_tools=None,
37
+ name=None,
38
+ max_retries=None,
39
+ ):
40
+ self.system_prompt = system_prompt if system_prompt is not None else self.system_prompt
41
+ self.model = model if model is not None else self.model
42
+ self.reasoning_effort = reasoning_effort if reasoning_effort is not None else self.reasoning_effort
43
+ self.verbosity = verbosity if verbosity is not None else self.verbosity
44
+ self.tools = list(tools if tools is not None else self.tools)
45
+ self.tool_choice = tool_choice if tool_choice is not None else self.tool_choice
46
+ self.output_schema = output_schema if output_schema is not None else self.output_schema
47
+ self.resolve_tools = resolve_tools if resolve_tools is not None else self.resolve_tools
48
+ self.name = name if name is not None else self.name
49
+ self.max_retries = max_retries if max_retries is not None else self.max_retries
50
+ self._client = client
51
+
52
+ @property
53
+ def client(self):
54
+ if self._client is None:
55
+ self._client = CodexClient(model=self.model, max_retries=self.max_retries).authenticate()
56
+ return self._client
57
+
58
+ def get_tools(self):
59
+ return [
60
+ tool.to_response_tool() if hasattr(tool, "to_response_tool") else dict(tool)
61
+ for tool in self.tools
62
+ ]
63
+
64
+ def get_local_tools(self):
65
+ return {
66
+ tool.name: tool
67
+ for tool in self.tools
68
+ if isinstance(tool, Tool)
69
+ }
70
+
71
+ def run(
72
+ self,
73
+ prompt,
74
+ *,
75
+ conversation_history=None,
76
+ tools=None,
77
+ model=None,
78
+ reasoning_effort=None,
79
+ verbosity=None,
80
+ tool_choice=None,
81
+ output_schema=None,
82
+ resolve_tools=None,
83
+ ):
84
+ text = ""
85
+ reasoning = ""
86
+ tool_calls = []
87
+ tool_results = []
88
+ output_items = []
89
+
90
+ try:
91
+ resolved_tools = tools if tools is not None else self.get_tools()
92
+ resolved_tool_choice = tool_choice or self.tool_choice or ("auto" if resolved_tools else "none")
93
+ resolved_output_schema = output_schema if output_schema is not None else self.output_schema
94
+ resolved_verbosity = verbosity or self.verbosity
95
+
96
+ events = self.client.stream(
97
+ prompt,
98
+ conversation_history=conversation_history or [],
99
+ instructions=self.system_prompt,
100
+ model=model or self.model,
101
+ reasoning=reasoning_effort or self.reasoning_effort,
102
+ reasoning_summary="auto" if (reasoning_effort or self.reasoning_effort) else None,
103
+ include_reasoning=bool(reasoning_effort or self.reasoning_effort),
104
+ verbosity=None if resolved_output_schema is not None else resolved_verbosity,
105
+ tools=resolved_tools,
106
+ tool_choice=resolved_tool_choice,
107
+ parallel_tool_calls=False,
108
+ output_schema=resolved_output_schema,
109
+ )
110
+ for event in events:
111
+ if isinstance(event, TextDelta):
112
+ text += event.text
113
+ elif isinstance(event, ReasoningDelta):
114
+ reasoning += event.text
115
+ elif isinstance(event, ToolCall):
116
+ tool_calls.append(event.raw)
117
+ should_resolve = self.resolve_tools if resolve_tools is None else resolve_tools
118
+ if should_resolve and len(tool_results) == 0:
119
+ tool = self.get_local_tools().get(event.name)
120
+ if tool is not None:
121
+ output = str(tool(**event.parsed_arguments()))
122
+ tool_results.append(modict(
123
+ call_id=event.call_id,
124
+ name=event.name,
125
+ output=output,
126
+ ))
127
+ elif isinstance(event, OutputItem):
128
+ output_items.append(event.raw)
129
+ elif isinstance(event, ResponseFailed):
130
+ raise RuntimeError(f"[{event.code}] {event.message}")
131
+ except ResponseAborted:
132
+ raise
133
+
134
+ if tool_results:
135
+ tool_output = "\n".join(result.output for result in tool_results)
136
+ text = f"{text}\n{tool_output}".strip() if text else tool_output
137
+
138
+ return modict(
139
+ content=text,
140
+ reasoning=reasoning,
141
+ tool_calls=tool_calls,
142
+ tool_results=tool_results,
143
+ output_items=output_items,
144
+ )
145
+
146
+ def __call__(self, prompt, **kwargs):
147
+ return self.run(prompt, **kwargs).content
148
+
149
+ def abort(self):
150
+ if self._client is not None:
151
+ self._client.abort()
152
+
153
+
154
+ class SummarizerWorker(Worker):
155
+ name = "summarizer"
156
+ system_prompt = """You are a focused summarization worker.
157
+
158
+ Return a compact, faithful summary of the supplied content.
159
+ Preserve names, decisions, constraints, unresolved questions, and concrete facts.
160
+ Remove repetition and conversational filler.
161
+ """
162
+
163
+
164
+ class TurnSummaryWorker(Worker):
165
+ name = "turn_summary"
166
+ model = "gpt-5.4-mini"
167
+ system_prompt = """You are a RAG archive worker.
168
+
169
+ Summarize one complete conversation turn into exactly one dense, semantically meaningful paragraph.
170
+ This will be used to populate a long-term memory store that the AI assistant can retrieve from in future conversations.
171
+ Focus on what is worth remembering long term for the AI assistant, not on the incidental details that are only relevant in the moment.
172
+ The turn may contain the user request, assistant messages and reasoning summaries, tool calls, tool results, server-tool outputs, generated files, errors, interruptions, and the final assistant response.
173
+ Preserve the user's intent, important facts, constraints, decisions, actions performed, concrete artifacts such as file paths or commands, final outcome, and any unresolved state.
174
+ Ignore operational noise, raw payloads, base64 data, duplicate stream fragments, and incidental logs unless they changed the outcome.
175
+ If the turn does not contain anything genuinely useful to remember long term, such as a simple greeting, thanks, acknowledgement, or other exchange with no durable user information, durable decision, artifact, or unresolved state, return an empty string.
176
+ Return only the paragraph, with no title, bullets, Markdown, JSON, or commentary.
177
+ """
178
+
179
+ def summarize_turn(self, turn, previous_summary=None, **kwargs):
180
+ prompt = "\n\n".join(message.as_string() for message in turn)
181
+ if previous_summary:
182
+ prompt = (
183
+ "Previous turn summary for background only. Do not summarize this previous summary; "
184
+ "use it only to understand the immediate prior context and formulate the new turn summary better.\n"
185
+ f"<previous_turn_summary>\n{previous_summary}\n</previous_turn_summary>\n\n"
186
+ "<turn_to_summarize>\n"
187
+ f"{prompt}\n"
188
+ "</turn_to_summarize>"
189
+ )
190
+ return self.run(prompt, **kwargs).content