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.
@@ -0,0 +1,645 @@
1
+ from queue import Queue
2
+ from inspect import isgenerator
3
+ from threading import ThreadError
4
+ import time
5
+ import regex # Third-party regex module
6
+ import re
7
+ from typing import Generator, List, Iterable, Callable, Tuple, Union
8
+ from .utils import tokenize, Thread
9
+ from textwrap import dedent
10
+ import json
11
+
12
+ def log(*data):
13
+ if False:
14
+ print(*data)
15
+ input()
16
+
17
+
18
+ class END:
19
+ #sentinel value for end of stream
20
+ pass
21
+
22
+ class Task:
23
+
24
+ def __init__(self,target=None,args=None,kwargs=None):
25
+ self.target_task=target or self.target
26
+ self.args=args if args is not None else ()
27
+ self.kwargs=kwargs if kwargs is not None else {}
28
+ self.thread=None
29
+
30
+ def target(self,*args,**kwargs):
31
+ pass
32
+
33
+ def start(self):
34
+ self.thread=Thread(target=self.target_task,args=self.args,kwargs=self.kwargs)
35
+ self.thread.start()
36
+
37
+ def join(self):
38
+ self.thread.join()
39
+
40
+ class Tee:
41
+
42
+ def __init__(self):
43
+ self.queues={}
44
+ self.thread=None
45
+
46
+ def target(self,stream):
47
+ for chunk in stream:
48
+ for k in self.queues:
49
+ self.queues[k].put(chunk)
50
+ time.sleep(0.0005)
51
+ for k in self.queues:
52
+ self.queues[k].put(END)
53
+
54
+ def tee(self,stream, n=2):
55
+ self.queues={k:Queue() for k in range(n)}
56
+ self.thread=Thread(target=self.target, args=(stream,),kwargs=dict(n=n))
57
+ self.thread.start()
58
+ readers={}
59
+ for k in self.queues:
60
+ def reader():
61
+ while not (processed_chunk:=self.queues[k].get()) is END:
62
+ yield processed_chunk
63
+ readers[k]=reader()
64
+ return tuple(readers.values())
65
+
66
+ class Splitter:
67
+
68
+ def __init__(self,condition=None):
69
+ self.queue_true=None
70
+ self.queue_false=None
71
+ self.condition=condition or self.split_condition
72
+ self.thread=None
73
+
74
+ def split_condition(self,chunk):
75
+ return True
76
+
77
+ def target(self,stream):
78
+ for chunk in stream:
79
+ if self.condition(chunk):
80
+ self.queue_true.put(chunk)
81
+ else:
82
+ self.queue_false.put(chunk)
83
+ self.queue_true.put(END)
84
+ self.queue_false.put(END)
85
+
86
+ def split(self, stream):
87
+ self.queue_true=Queue()
88
+ self.queue_false=Queue()
89
+ self.thread=Thread(target=self.target, args=(stream,))
90
+ self.thread.start()
91
+ def reader_true():
92
+ while not (chunk:=self.queue_true.get()) is END:
93
+ yield chunk
94
+ def reader_false():
95
+ while not (chunk:=self.queue_false.get()) is END:
96
+ yield chunk
97
+ return reader_true(),reader_false()
98
+
99
+ class Streamer:
100
+
101
+ def __init__(self,stream_processor=None,threaded=True):
102
+ self.queue=None
103
+ self.threaded=threaded
104
+ self.process_stream=stream_processor or self.stream_processor
105
+
106
+ def stream_processor(self,stream):
107
+ """
108
+ Default stream processor : Just yields the stream, does nothing else
109
+ Can be overriden in subclasses or bypassed by providing a stream_processor to the instance
110
+ """
111
+ return stream
112
+
113
+ def target(self,stream):
114
+ for processed_chunk in self.process_stream(stream):
115
+ time.sleep(0.0005)
116
+ self.queue.put(processed_chunk)
117
+ self.queue.put(END)
118
+
119
+ def process(self,stream):
120
+ if self.threaded:
121
+ self.queue=Queue()
122
+ self.thread=Thread(target=self.target, args=(stream,))
123
+ self.thread.daemon=True
124
+ self.thread.start()
125
+ def reader():
126
+ while not (processed_chunk:=self.queue.get()) is END:
127
+ yield processed_chunk
128
+ return reader()
129
+ else:
130
+ return self.process_stream(stream)
131
+
132
+ def __call__(self, stream):
133
+ return self.process(stream)
134
+
135
+ class StreamLogger(Streamer):
136
+
137
+ def __init__(self,threaded=True,file=None):
138
+ super().__init__(threaded=threaded)
139
+ self.file=file
140
+
141
+ def stream_processor(self, stream):
142
+ output=""
143
+ for token in stream:
144
+ if token:
145
+ output += token
146
+ yield token
147
+ if self.file is not None:
148
+ import os
149
+ if not os.path.isfile(self.file):
150
+ open(self.file,'w', encoding='utf-8').close()
151
+ with open(self.file,'a', encoding='utf-8') as f:
152
+ f.write(output)
153
+
154
+ def fenced(start: Union[str,regex.Pattern,re.Pattern], end: Union[str,regex.Pattern,re.Pattern], flags=0) -> regex.Pattern:
155
+ r"""
156
+ Generates a regex pattern that matches text enclosed between a start and an end delimiter,
157
+ The inner part is captured as the 'content' group (default).
158
+ There can be other captured groups defined in the start and end patterns.
159
+ The end delimiter can include backreferences to captured groups from the opening fence into placeholders in the closing fence.
160
+
161
+ The closing fence pattern may include placeholders in the form of "#1#" for the first captured group,
162
+ "#2#" for the second, or "#name#" for a named capturing group; these will be replaced by the appropriate
163
+ backreference. This mechanism is completely generic and does not enforce any specific delimiter shapes.
164
+
165
+ Example:
166
+ # Using a numeric placeholder:
167
+ pattern = fenced(r"START (\w+)", r"END #1#", flags=regex.DOTALL)
168
+ text = "START content END content"
169
+ match = pattern.match(text)
170
+ if match:
171
+ print(match.group())
172
+
173
+ # Using a named capturing group:
174
+ pattern = fenced(r"BEGIN (?P<tag>\w+)", r"FIN #tag#", flags=regex.DOTALL)
175
+ text = "BEGIN example FIN example"
176
+ match = pattern.match(text)
177
+ if match:
178
+ print(match.group())
179
+
180
+ :param start: Opening delimiter (str or regex.Pattern). May contain capturing groups.
181
+ :param end: Closing delimiter (str or regex.Pattern) which may include placeholders (e.g., "#1#", "#name#").
182
+ :param flags: Regex flags to modify behavior (e.g., regex.DOTALL, regex.MULTILINE).
183
+ :return: A compiled regex pattern.
184
+ """
185
+ def prepare_pattern(pattern):
186
+ if isinstance(pattern, (regex.Pattern,re.Pattern)):
187
+ return pattern.pattern
188
+ if isinstance(pattern, str):
189
+ return pattern
190
+ raise ValueError("`start` and `end` must be either a string or a regex Pattern object")
191
+
192
+ start_regex = prepare_pattern(start)
193
+ end_regex = prepare_pattern(end)
194
+
195
+ # Replace placeholders in end pattern with regex backrefs
196
+ def replace_placeholder(match):
197
+ key = match.group(1)
198
+ if key.isdigit():
199
+ return rf"\{key}"
200
+ else:
201
+ return rf"(?P={key})"
202
+ end_regex = regex.sub(r"#(\w+)#", replace_placeholder, end_regex)
203
+
204
+ # Named content group
205
+ inner = rf'(?P<content>(?:(?!{end_regex})[\s\S])*)'
206
+
207
+ full_pattern = rf'{start_regex}{inner}(?:{end_regex}|$)'
208
+ return regex.compile(full_pattern, flags)
209
+
210
+ def is_compiled_pattern(pattern):
211
+ if isinstance(pattern, (re.Pattern,regex.Pattern)):
212
+ return True
213
+ elif isinstance(pattern,str):
214
+ return False
215
+ else:
216
+ raise ValueError(f"Expected either a string, re.Pattern or regex.Pattern object. got {type(pattern)}")
217
+
218
+ def is_re_pattern(pattern):
219
+ return isinstance(pattern,re.Pattern)
220
+
221
+ class TokenStreamer(Streamer):
222
+
223
+ def __init__(self, patterns, threaded=True):
224
+ """
225
+ :param patterns: List of tuples (pattern, processing_func).
226
+ `pattern` peut être une string, un re.Pattern ou un regex.Pattern.
227
+ `processing_func` doit être de la forme:
228
+ lambda match: output_str
229
+ """
230
+ super().__init__(threaded=threaded)
231
+ self.compiled_patterns = []
232
+ for pattern, func in patterns:
233
+ if is_compiled_pattern(pattern):
234
+ if is_re_pattern(pattern):
235
+ # recompile avec regex pour autoriser les partial matches
236
+ self.compiled_patterns.append(
237
+ (regex.compile(pattern.pattern, flags=pattern.flags), func)
238
+ )
239
+ else:
240
+ self.compiled_patterns.append((pattern, func))
241
+ else:
242
+ self.compiled_patterns.append((regex.compile(pattern), func))
243
+
244
+ def update_candidates(self, buffer, candidates):
245
+ """
246
+ Pour chaque pattern, on tente de matcher le buffer en mode partial.
247
+ Si le match est trouvé, on le stocke dans candidates.
248
+
249
+ candidates: dict[index_pattern] = (match_obj, is_complete: bool)
250
+ """
251
+ if buffer:
252
+ for i, (pattern, _) in enumerate(self.compiled_patterns):
253
+ match = regex.match(pattern, buffer, partial=True)
254
+ if match:
255
+ # match_obj = match
256
+ # Si le match s'étend jusqu'à la fin du buffer, on ne peut pas conclure.
257
+ if match.span()[1] == len(buffer):
258
+ candidates[i] = (match, False)
259
+ else:
260
+ # Des caractères en plus après le match → pattern terminé.
261
+ candidates[i] = (match, True)
262
+ else:
263
+ candidates.pop(i, None)
264
+ return candidates
265
+
266
+ def compute_output(self, buffer, candidates):
267
+ """
268
+ Si un unique candidat est présent et marqué comme complet, on le traite et on le transforme.
269
+ On ne valide le candidat que s'il ne couvre PAS exactement tout le buffer
270
+ (évite de traiter un match susceptible d'être prolongé).
271
+ """
272
+ if len(candidates) == 1:
273
+ key = list(candidates.keys())[0]
274
+ match, is_complete = candidates[key]
275
+ full = match.group(0)
276
+
277
+ # Ne traiter que si le match est complet ET que le buffer contient plus que le candidat
278
+ if is_complete and len(full) < len(buffer):
279
+ _, func = self.compiled_patterns[key]
280
+ new_buffer = buffer[len(full):]
281
+ del candidates[key]
282
+ # On passe directement le match à la fonction de traitement
283
+ return func(match), new_buffer, candidates
284
+ else:
285
+ return None, buffer, candidates
286
+
287
+ if len(candidates) == 0:
288
+ if buffer:
289
+ # Pas de pattern détecté, on avance d'un caractère.
290
+ char = buffer[0]
291
+ new_buffer = buffer[1:]
292
+ return char, new_buffer, candidates
293
+ else:
294
+ return None, buffer, candidates
295
+ else:
296
+ return None, buffer, candidates
297
+
298
+ def yield_greediest(self, buffer):
299
+ """
300
+ Lors du vidage du buffer, on tente de trouver parmi tous les patterns celui qui correspond le mieux
301
+ (le plus long match commençant au début) et on le traite.
302
+ """
303
+ matching = []
304
+ for pattern, func in self.compiled_patterns:
305
+ match = regex.match(pattern, buffer)
306
+ if match and match.span()[0] == 0:
307
+ matching.append((func, match))
308
+
309
+ if matching:
310
+ # On choisit le match le plus long
311
+ func, match = sorted(matching, key=lambda x: len(x[1].group(0)), reverse=True)[0]
312
+ full = match.group(0)
313
+ processed = func(match)
314
+ new_buffer = buffer[len(full):]
315
+ return processed, new_buffer
316
+ else:
317
+ return None, buffer
318
+
319
+ def stream_processor(self, stream):
320
+ """
321
+ Accumule les tokens dans un buffer et tente de détecter, dès que possible, des patterns à transformer.
322
+ """
323
+ buffer = ''
324
+ candidates = {}
325
+
326
+ for token in stream:
327
+ if not token:
328
+ continue
329
+
330
+ buffer += token
331
+ keep_on_checking = True
332
+
333
+ while keep_on_checking:
334
+ candidates = self.update_candidates(buffer, candidates)
335
+ output, buffer, candidates = self.compute_output(buffer, candidates)
336
+ if output is not None:
337
+ yield output
338
+ else:
339
+ keep_on_checking = False
340
+
341
+ # Vidage du buffer une fois le flux terminé
342
+ while buffer:
343
+ processed, buffer = self.yield_greediest(buffer)
344
+ if processed:
345
+ yield processed
346
+ else:
347
+ yield buffer[0]
348
+ buffer = buffer[1:]
349
+
350
+ class Extractor(Streamer):
351
+
352
+ def __init__(self, pattern, ignore=None, threaded=True):
353
+ super().__init__(threaded=threaded)
354
+ ignore=ignore or []
355
+ self.token_streamer = TokenStreamer([
356
+ # ignore processing for patterns in ignore, we just keep them in the output
357
+ *[(ignored,(lambda m:m.group(0))) for ignored in ignore],
358
+ (pattern, self.process_match)
359
+ ], threaded=False)
360
+ self.matchs=[]
361
+
362
+ def stream_processor(self, stream):
363
+ self.matchs=[]
364
+ return self.token_streamer(stream)
365
+
366
+ def process_match(self,match):
367
+ self.matchs.append(match)
368
+ return ''
369
+
370
+ class XMLExtractor(Extractor):
371
+ # Attributs XML : string, int, float, bool, null, json object/list
372
+ ATTR_RE = regex.compile(
373
+ r"""
374
+ (?P<name>[A-Za-z_][\w:.-]*) # nom
375
+ \s*=\s*
376
+ (
377
+ (?P<quote>["'])(?P<qvalue>.*?)(?P=quote) # valeur "quoted"
378
+ |
379
+ (?P<uvalue>[^\s>]+) # valeur non-quotée
380
+ )
381
+ """,
382
+ regex.VERBOSE | regex.DOTALL,
383
+ )
384
+
385
+ def __init__(self,threaded=True):
386
+ pattern = fenced(
387
+ r"<(?P<tool>[A-Za-z_][\w\-.]*)"
388
+ r"(?P<attrs>[^>]*)>",
389
+ r"</#tool#>",
390
+ flags=regex.DOTALL,
391
+ )
392
+ ignore=[fenced('```','```',flags=re.DOTALL|re.MULTILINE)]
393
+ super().__init__(pattern, ignore=ignore, threaded=threaded)
394
+
395
+ @classmethod
396
+ def parse_attrs(cls, attrs_src: str) -> dict:
397
+ attrs_src = attrs_src or ""
398
+ result = {}
399
+
400
+ for m in cls.ATTR_RE.finditer(attrs_src):
401
+ name = m.group("name")
402
+ raw = m.group("qvalue") or m.group("uvalue") or ""
403
+
404
+ try:
405
+ # JSON-compatible values: numbers, booleans, null, dicts, lists…
406
+ value = json.loads(raw)
407
+ except Exception:
408
+ # else keep raw string
409
+ value = raw
410
+
411
+ result[name] = value
412
+
413
+ return result
414
+
415
+ def process_match(self, match):
416
+ tool = match.group("tool")
417
+ attrs_src = match.group("attrs") or ""
418
+ content = match.group("content") or ""
419
+
420
+ args = self.parse_attrs(attrs_src)
421
+ args["content"] = content.strip('\n') # 🔥 INTÉGRATION DIRECTE ICI 🔥
422
+
423
+ self.matchs.append({
424
+ "name": tool,
425
+ "args": args, # dict final prêt pour un tool call
426
+ "match": match,
427
+ })
428
+ return f'```Called {tool!r} tool via XML (tool call details accessible in message.tool_calls)```' # on retire le bloc XML du texte visible
429
+
430
+ class MarkdownBlockExtractor(Extractor):
431
+ """
432
+ En fait maintenant : extracteur de blocs markdown tool.
433
+
434
+ Forme reconnue :
435
+
436
+ ```tool_name(foo=1, bar="x", debug=true)
437
+ ...content...
438
+ ```
439
+
440
+ -> match :
441
+ - tool : "tool_name"
442
+ - args : dict typé (int, float, bool, None, objets/lists JSON...)
443
+ - content: texte intérieur (multi-ligne)
444
+ """
445
+
446
+ KWARG_RE = regex.compile(
447
+ r"""
448
+ (?P<name>[A-Za-z_]\w*)
449
+ \s*=\s*
450
+ (
451
+ (?P<quote>["'])(?P<qvalue>.*?)(?P=quote)
452
+ |
453
+ (?P<uvalue>[^,\s)]+)
454
+ )
455
+ (?:\s*,\s*|$)
456
+ """,
457
+ regex.VERBOSE | regex.DOTALL,
458
+ )
459
+
460
+ def __init__(self, ignore=None, threaded=True):
461
+ # Exactly 3 backticks: (?<!`)```(?!`)
462
+ start = (
463
+ r"(?<!`)```(?!`)" # EXACTLY 3 backticks
464
+ r"(?P<tool>[A-Za-z_][\w\-.]*)" # tool name
465
+ r"(?:\((?P<raw_args>[^\n)]*)\))?" # optional (...args...)
466
+ r"[^\n]*\n" # rest of the line + newline
467
+ )
468
+
469
+ end = r"(?<!`)```(?!`)[ \t]*" # closing fence (exactly 3)
470
+
471
+ pattern = fenced(start, end, flags=regex.DOTALL)
472
+ super().__init__(pattern, ignore=ignore, threaded=threaded)
473
+
474
+ @classmethod
475
+ def parse_kwargs(cls, raw_args: str) -> dict:
476
+ raw_args = (raw_args or "").strip()
477
+ if not raw_args:
478
+ return {}
479
+
480
+ result = {}
481
+ for m in cls.KWARG_RE.finditer(raw_args):
482
+ name = m.group("name")
483
+ raw_val = m.group("qvalue") or m.group("uvalue") or ""
484
+ try:
485
+ value = json.loads(raw_val)
486
+ except Exception:
487
+ value = raw_val
488
+ result[name] = value
489
+
490
+ return result
491
+
492
+ def process_match(self, match):
493
+ tool = match.group("tool")
494
+ raw_args = match.group("raw_args") or "" # <= si pas de (), ça devient ""
495
+ content = match.group("content") or ""
496
+
497
+ args = self.parse_kwargs(raw_args)
498
+ args["content"] = content
499
+
500
+ self.matchs.append({
501
+ "name": tool,
502
+ "args": args,
503
+ "match": match,
504
+ })
505
+
506
+ # On renvoie le bloc tel quel
507
+ return match.group()
508
+
509
+ class MappingStreamSplitter:
510
+
511
+ """converts a stream of Mappings into a dict of streams, one for each key of the Mapping data"""
512
+
513
+ def __init__(self, defaults=None, threaded=False):
514
+ self.threaded=threaded
515
+ self.readers=dict()
516
+ self.queues=dict()
517
+ self.defaults=defaults or {}
518
+ self.done=False
519
+
520
+ def maybe_init_readers(self, data):
521
+ for key in data.keys():
522
+ if key not in self.readers:
523
+ self.queues[key]=Queue()
524
+ def reader(key=key):
525
+ while (value:=self.queues[key].get()) is not END:
526
+ yield value
527
+ self.readers[key]=reader()
528
+
529
+ def process(self,stream):
530
+ self.queues=dict()
531
+ self.done=False
532
+ if self.defaults:
533
+ self.maybe_init_readers(self.defaults)
534
+ for data in stream:
535
+ self.maybe_init_readers(data)
536
+ for key, value in data.items():
537
+ self.queues[key].put(value)
538
+ for key in set(self.defaults.keys())-set(data.keys()):
539
+ self.queues[key].put(self.defaults[key])
540
+ if self.threaded:
541
+ time.sleep(0.0005)
542
+ for key in self.queues:
543
+ self.queues[key].put(END)
544
+ self.done=True
545
+
546
+ def split(self, stream):
547
+ if self.threaded:
548
+ thread=Thread(target=self.process, args=(stream,))
549
+ thread.start()
550
+ time.sleep(0.01) # let (at least) the default readers be initialized
551
+ else:
552
+ self.process(stream)
553
+ return self.readers
554
+ #warning! in threaded mode, readers will be added dynamically as new keys are encountered
555
+ #it's better suited for streams of Mappings with a predictable set of keys
556
+
557
+ def __call__(self, stream):
558
+ return self.split(stream)
559
+
560
+ class MappingStreamGatherer:
561
+
562
+ """
563
+ Does it backwards, it takes a dict of streams and returns a stream of Mappings of the chosen type (default, dicts)
564
+ """
565
+
566
+ def __init__(self, defaults=None, type=None, threaded=False):
567
+ self.type=type or dict
568
+ self.defaults=defaults or {}
569
+ self.threaded=threaded
570
+ self.queue=None
571
+
572
+ def process(self, streams:dict, processor_for_key=None, splitter=None):
573
+ active_streams={}
574
+ while True:
575
+ data={}
576
+ for key, stream in list(streams.items()): #list because new readers may appear or be deleted as we loop
577
+ stream = active_streams.get(key) or stream
578
+ if processor_for_key and key not in active_streams:
579
+ stream = active_streams[key] = processor_for_key(key, stream)
580
+ try:
581
+ data[key]=next(stream)
582
+ except StopIteration:
583
+ # the stream is consumed
584
+ del streams[key]
585
+ active_streams.pop(key, None)
586
+ if data:
587
+ for key, default in self.defaults.items():
588
+ data.setdefault(key, default)
589
+ self.queue.put(self.type(data))
590
+ if not streams:
591
+ if splitter is not None and not splitter.done:
592
+ time.sleep(0.0005)
593
+ continue
594
+ break
595
+ if splitter is not None and not splitter.done:
596
+ time.sleep(0.0005)
597
+ self.queue.put(END)
598
+
599
+ def gather(self, streams:dict, processor_for_key=None, splitter=None):
600
+ self.queue=Queue()
601
+ if self.threaded:
602
+ Thread(target=self.process, args=(streams, processor_for_key, splitter)).start()
603
+ else:
604
+ self.process(streams, processor_for_key=processor_for_key, splitter=splitter)
605
+
606
+ def stream():
607
+ while (mapping:=self.queue.get()) is not END:
608
+ yield mapping
609
+
610
+ return stream()
611
+
612
+ def __call__(self, streams:dict, processor_for_key=None, splitter=None):
613
+ return self.gather(streams, processor_for_key=processor_for_key, splitter=splitter)
614
+
615
+ class MappingStreamProcessor(Streamer):
616
+
617
+ """
618
+ Allows to plug specific processors (Streamers) to specific keys of a stream of mappings
619
+ """
620
+
621
+ def __init__(self, defaults=None, processors=None, type=None, threaded=False):
622
+ super().__init__(threaded=threaded)
623
+ self.type=type if type is not None else dict
624
+ self.defaults=defaults if defaults is not None else {}
625
+ self.processors=processors if processors is not None else {}
626
+ self.splitter=MappingStreamSplitter(defaults=self.defaults, threaded=True)
627
+ self.gatherer=MappingStreamGatherer(defaults=self.defaults, type=self.type, threaded=True)
628
+
629
+ def stream_processor(self, stream):
630
+ if not self.processors:
631
+ return stream
632
+ streams=self.splitter(stream)
633
+ def processor_for_key(key, stream):
634
+ processor=self.processors.get(key)
635
+ if isinstance(processor,list):
636
+ for proc in reversed(processor):
637
+ stream=proc(stream)
638
+ elif callable(processor):
639
+ stream=processor(stream)
640
+ return stream
641
+ return self.gatherer(streams, processor_for_key=processor_for_key, splitter=self.splitter)
642
+
643
+
644
+
645
+