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/tool.py ADDED
@@ -0,0 +1,235 @@
1
+ from .message import DeveloperMessage
2
+ from contextvars import ContextVar
3
+ from modict import modict
4
+ from typing import get_args, get_origin, Literal, Union
5
+ import json
6
+ import inspect
7
+
8
+ TOOL_MARKER = "__codex_agent_tool__"
9
+ _current_agent = ContextVar("codex_agent_current_agent", default=None)
10
+
11
+
12
+ def tool(func=None, **options):
13
+ """Mark a function as an agent tool for automatic discovery."""
14
+ if func is None:
15
+ def decorator(f):
16
+ return tool(f, **options)
17
+ return decorator
18
+
19
+ setattr(func, TOOL_MARKER, modict(options))
20
+ return func
21
+
22
+
23
+ def tool_options(func):
24
+ return getattr(func, TOOL_MARKER, None)
25
+
26
+
27
+ def get_agent():
28
+ agent = _current_agent.get()
29
+ if agent is None:
30
+ raise RuntimeError("No current agent is available outside an agent tool call.")
31
+ return agent
32
+
33
+
34
+ def set_current_agent(agent):
35
+ return _current_agent.set(agent)
36
+
37
+
38
+ def reset_current_agent(token):
39
+ _current_agent.reset(token)
40
+
41
+
42
+ class ServerTool(modict):
43
+ """Responses API server-side tool without a local Python handler."""
44
+
45
+ type = None
46
+
47
+ def to_response_tool(self):
48
+ return {key: value for key, value in self.items() if value is not None}
49
+
50
+
51
+ class WebSearchTool(ServerTool):
52
+ type = "web_search"
53
+
54
+
55
+ class ImageGenerationTool(ServerTool):
56
+ type = "image_generation"
57
+ output_format = "png"
58
+ size = None
59
+ quality = None
60
+ background = None
61
+ moderation = None
62
+
63
+
64
+ class Tool:
65
+ """Local tool callable exposed to the model."""
66
+
67
+ def __init__(
68
+ self,
69
+ obj,
70
+ name=None,
71
+ description=None,
72
+ parameters=None,
73
+ required=None,
74
+ type=None,
75
+ mode=None,
76
+ format=None,
77
+ ):
78
+ self.obj=obj
79
+ self.name=name or getattr(obj,'__name__',None) or obj.__class__.__name__
80
+ self.schema:modict=self.infer_schema()
81
+ self.schema.update(self.parse_doc())
82
+ if description:
83
+ self.schema.description=description
84
+ if parameters:
85
+ self.schema.parameters=parameters
86
+ if required:
87
+ self.schema.required=required
88
+ if format:
89
+ self.schema.format=format
90
+ self.mode = mode or self.schema.pop('mode', None) or 'api'
91
+ self.type = type or self.schema.pop('type',None) or 'function'
92
+ self.format = self.schema.pop('format', None)
93
+
94
+ def __getattr__(self,name):
95
+ return getattr(self.obj,name)
96
+
97
+ def __call__(self,*args,**kwargs):
98
+ return self.obj(*args,**kwargs)
99
+
100
+ def infer_schema(self):
101
+ try:
102
+ signature = inspect.signature(self.obj)
103
+ except (TypeError, ValueError):
104
+ return modict()
105
+
106
+ parameters = modict()
107
+ required = []
108
+ for name, parameter in signature.parameters.items():
109
+ if name in ("self", "cls"):
110
+ continue
111
+ if parameter.kind in (
112
+ inspect.Parameter.VAR_POSITIONAL,
113
+ inspect.Parameter.VAR_KEYWORD,
114
+ ):
115
+ continue
116
+
117
+ schema = self.annotation_to_schema(parameter.annotation)
118
+ if parameter.default is inspect.Parameter.empty:
119
+ required.append(name)
120
+ else:
121
+ schema["default"] = parameter.default
122
+ parameters[name] = schema
123
+
124
+ return modict(parameters=parameters, required=required)
125
+
126
+ def annotation_to_schema(self, annotation):
127
+ if annotation is inspect.Parameter.empty:
128
+ return {"type": "string"}
129
+
130
+ origin = get_origin(annotation)
131
+ args = get_args(annotation)
132
+
133
+ if origin is Literal:
134
+ values = list(args)
135
+ schema = {"enum": values}
136
+ if values:
137
+ schema["type"] = self.json_type(type(values[0]))
138
+ return schema
139
+
140
+ if origin in (list, tuple, set):
141
+ item_type = args[0] if args else str
142
+ return {
143
+ "type": "array",
144
+ "items": self.annotation_to_schema(item_type),
145
+ }
146
+
147
+ if origin is dict:
148
+ return {"type": "object"}
149
+
150
+ if origin is Union:
151
+ non_null_args = [arg for arg in args if arg is not type(None)]
152
+ if len(non_null_args) == 1:
153
+ schema = self.annotation_to_schema(non_null_args[0])
154
+ schema["nullable"] = True
155
+ return schema
156
+ return {"anyOf": [self.annotation_to_schema(arg) for arg in non_null_args]}
157
+
158
+ return {"type": self.json_type(annotation)}
159
+
160
+ def json_type(self, python_type):
161
+ return {
162
+ str: "string",
163
+ int: "integer",
164
+ float: "number",
165
+ bool: "boolean",
166
+ dict: "object",
167
+ list: "array",
168
+ tuple: "array",
169
+ set: "array",
170
+ }.get(python_type, "string")
171
+
172
+ def parse_doc(self):
173
+ """Parses the YAML docstring of the tool's function to extract metadata.
174
+
175
+ Returns:
176
+ modict: Schema containing description, parameters, and required fields.
177
+ """
178
+ import yaml
179
+ from textwrap import dedent
180
+
181
+ doc = self.obj.__doc__
182
+ if not doc:
183
+ return modict()
184
+
185
+ # Nettoie l'indentation avec dedent, puis strip les quotes et espaces
186
+ doc_str = dedent(doc).strip().strip('"""').strip("'''").strip()
187
+
188
+ try:
189
+ schema = yaml.safe_load(doc_str)
190
+ if not isinstance(schema, dict):
191
+ return modict(description=doc_str)
192
+ except Exception:
193
+ return modict(description=doc_str)
194
+
195
+ return modict(schema)
196
+
197
+ def to_response_tool(self):
198
+ """Converts the Tool object into a Responses API tool schema."""
199
+ if self.type == "custom":
200
+ tool = dict(
201
+ type="custom",
202
+ name=self.name,
203
+ description=self.schema.get('description','No description provided'),
204
+ )
205
+ if self.format:
206
+ tool["format"] = dict(self.format)
207
+ return tool
208
+
209
+ properties=dict()
210
+ for name,param in self.schema.get('parameters',{}).items():
211
+ if isinstance(param,dict):
212
+ properties[name]=dict(param)
213
+ elif isinstance(param,str):
214
+ properties[name]=param
215
+
216
+ tool=dict(
217
+ type="function",
218
+ name=self.name,
219
+ description=self.schema.get('description','No description provided'),
220
+ parameters=dict(
221
+ type="object",
222
+ properties=properties,
223
+ required=self.schema.get('required',[])
224
+ ),
225
+ )
226
+ return tool
227
+
228
+ def to_developer_message(self):
229
+ """Converts the Tool object into a developer message for parsed tool guidance.
230
+ For tools that don't use the usual API function call pipeline, this can be used to provide the AI with the tool's schema in a more flexible way.
231
+
232
+ Returns:
233
+ Message: Developer message containing the tool's schema.
234
+ """
235
+ return DeveloperMessage(content=f"Tool: {self.name}\nSchema:\n{json.dumps(self.to_response_tool(), indent=2, ensure_ascii=False)}")
codex_agent/utils.py ADDED
@@ -0,0 +1,374 @@
1
+ import os
2
+ import shutil
3
+ import tiktoken
4
+ import re
5
+ from datetime import datetime
6
+ from textwrap import dedent
7
+ import random, string
8
+ import json
9
+ import regex
10
+ from threading import Thread as ThreadBase
11
+ import sys
12
+ import inspect
13
+ from importlib import import_module
14
+ from io import BytesIO
15
+
16
+ RUNTIME_DIR=os.path.expanduser(os.environ.get("AGENT_RUNTIME_DIR", "~/.agent_runtime"))
17
+
18
+ def runtime_join(*args):
19
+ return os.path.join(RUNTIME_DIR,*args)
20
+
21
+ def ensure_runtime_dir():
22
+ if not os.path.isdir(RUNTIME_DIR):
23
+ os.makedirs(RUNTIME_DIR,exist_ok=True)
24
+
25
+ def set_root_path(file=None):
26
+ file=file or __file__
27
+ os.environ['AGENT_ROOT_PATH']=os.path.dirname(os.path.abspath(file))
28
+
29
+ def root_join(*args):
30
+ return os.path.join(os.getenv('AGENT_ROOT_PATH'),*args)
31
+
32
+ def text_content(file):
33
+ if os.path.isfile(file):
34
+ with open(file,encoding="utf-8") as f:
35
+ return f.read()
36
+ else:
37
+ return None
38
+
39
+ def add_line_numbers(text: str) -> str:
40
+ lines = text.splitlines()
41
+ n = len(lines)
42
+ width = len(str(n))
43
+
44
+ lines_num = [f"{i:0{width}d}|{line}"
45
+ for i, line in enumerate(lines, start=1)]
46
+ return "\n".join(lines_num)
47
+
48
+ def short_id(length=8):
49
+ return "".join(random.choices(string.ascii_letters,k=length))
50
+
51
+ def session_id():
52
+ """Generate a unique, lexicographically sortable session ID."""
53
+ return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
54
+
55
+ tokenizer = tiktoken.get_encoding("cl100k_base")
56
+
57
+ def tokenize(string):
58
+ int_tokens = tokenizer.encode(string)
59
+ str_tokens = [tokenizer.decode([int_token]) for int_token in int_tokens]
60
+ return str_tokens
61
+
62
+ def utf8_safe_tokenize(s):
63
+ return regex.findall(r'\X', s)
64
+
65
+ def token_count(string):
66
+ return len(tokenizer.encode(string))
67
+
68
+ def sort(messages):
69
+ return sorted(messages, key=lambda msg: msg.timestamp)
70
+
71
+ def snake_case_type(cls):
72
+ name = cls.__name__
73
+ chars = []
74
+ for i, char in enumerate(name):
75
+ if char.isupper() and i > 0:
76
+ chars.append('_')
77
+ chars.append(char.lower())
78
+ return ''.join(chars)
79
+
80
+ def truncate(string, max_tokens=2000, start_line=1):
81
+ """
82
+ Truncate a string to a maximum number of tokens using line-based chunking.
83
+
84
+ Args:
85
+ string: The string to truncate
86
+ max_tokens: Maximum number of tokens to keep
87
+ start_line: The line number of the first line in the string (1-indexed).
88
+ Used for skipping content and tracking line numbers in truncation messages.
89
+
90
+ Returns:
91
+ Truncated string with indication of removed content
92
+ """
93
+ # Quick check - if already under limit, return as-is
94
+ tokens = tokenize(string)
95
+ if len(tokens) <= max_tokens:
96
+ return string
97
+
98
+ # line-based truncation
99
+ lines = string.splitlines(keepends=True)
100
+
101
+ # Skip to start_line if needed
102
+ if start_line > 1:
103
+ if start_line > len(lines):
104
+ return ""
105
+ lines = lines[start_line - 1:]
106
+
107
+ # Calculate the actual last line number after skipping
108
+ remaining_lines = len(lines)
109
+
110
+ # Assemble lines up to max_tokens
111
+ result_lines = []
112
+ current_tokens = 0
113
+
114
+ for line in lines:
115
+ line_tokens = token_count(line)
116
+ if current_tokens + line_tokens <= max_tokens:
117
+ result_lines.append(line)
118
+ current_tokens += line_tokens
119
+ else:
120
+ break
121
+
122
+ if result_lines:
123
+ result = ''.join(result_lines)
124
+ num_kept = len(result_lines)
125
+
126
+ # Only show truncation message if we didn't include all lines
127
+ if num_kept < remaining_lines:
128
+ first_truncated = start_line + num_kept
129
+ last_line = start_line + remaining_lines - 1
130
+
131
+ if not result.endswith('\n'):
132
+ result += '\n'
133
+ result += f"\n...\n\n[Lines {first_truncated}-{last_line} truncated]\n"
134
+
135
+ return result
136
+ else:
137
+ return f"[Lines {start_line}-{start_line + remaining_lines - 1} truncated - first lineexceeds token limit]\n"
138
+
139
+ def pack_msgs(messages):
140
+ text = ''
141
+ for message in messages:
142
+ text += message.name + ':\n'
143
+ text += message.content.strip() + '\n\n'
144
+ return text
145
+
146
+ def msg_token_count(msg):
147
+ count=0
148
+ if msg.type=='image_message':
149
+ count+=1000
150
+ count+=token_count(str(msg.get('description') or ''))
151
+ count+=token_count(str(msg.get('image_name') or msg.get('content') or ''))
152
+ else:
153
+ count+=token_count(str(msg.get('content') or ''))
154
+
155
+ for tool_call in msg.get('tool_calls') or []:
156
+ count+=token_count(json.dumps(tool_call.get('arguments','')))
157
+ return count
158
+
159
+ def total_tokens(messages):
160
+ return sum(msg_token_count(msg) for msg in messages)
161
+
162
+ def split_history_turns(messages):
163
+ """Split a message history into complete turns and a trailing remainder.
164
+
165
+ Any message can start a turn. The split is driven only by end markers:
166
+ a final assistant message, or an interruption marker. Assistant messages
167
+ with tool calls are intermediate loop messages, not final answers.
168
+ """
169
+ turns = []
170
+ current = []
171
+
172
+ def msg_type(msg):
173
+ return msg.get("type") if isinstance(msg, dict) else getattr(msg, "type", None)
174
+
175
+ def is_final_assistant(msg):
176
+ if msg_type(msg) != "assistant_message":
177
+ return False
178
+ tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else getattr(msg, "tool_calls", None)
179
+ return not tool_calls
180
+
181
+ def is_interruption_marker(msg):
182
+ if msg_type(msg) != "developer_message":
183
+ return False
184
+ content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", "")
185
+ return "interrupted by the user" in str(content).lower()
186
+
187
+ for msg in messages:
188
+ current.append(msg)
189
+ if is_final_assistant(msg) or is_interruption_marker(msg):
190
+ turns.append(current)
191
+ current = []
192
+
193
+ return turns, current
194
+
195
+ def find_pattern(text, pattern=None):
196
+ """
197
+ Renvoie une liste de tuples contenant TOUS les groupes capturés par le pattern.
198
+
199
+ - Si le pattern contient 1 groupe -> tuple de 1 élément
200
+ - Si le pattern contient n groupes -> tuple de n éléments
201
+ """
202
+
203
+ pattern = pattern or r'```python(.*?)```'
204
+ iterator = re.finditer(pattern, text, re.DOTALL)
205
+
206
+ return [
207
+ tuple(match.groups()) # groups() renvoie *tous* les groupes capturés
208
+ for match in iterator
209
+ ]
210
+
211
+ def format(string, context=None):
212
+ # Si aucun contexte n'est fourni, utiliser un dictionnaire vide
213
+ if context is None:
214
+ context = {}
215
+ # Trouver les expressions entre <<...>>
216
+ def replace_expr(match):
217
+ expr = match.group(1)
218
+ try:
219
+ # Évaluer l'expression dans le contexte donné et la convertir en chaîne
220
+ return str(eval(expr, context))
221
+ except Exception as e:
222
+ # print(f"could not evaluate expr: {expr}\n Exception:\n {str(e)}")
223
+ # En cas d'erreur, retourner l'expression non évaluée
224
+ return '<<' + expr + '>>'
225
+ # Remplacer chaque expression par son évaluation
226
+ return re.sub(r'<<(.*?)>>', replace_expr, string)
227
+
228
+ def timestamp(digits=3):
229
+ base = datetime.now().strftime("%Y%m%dT%H%M%S.%f")
230
+ if digits == 0:
231
+ return base[:15] # Jusqu'à la seconde uniquement
232
+ return base[:15 + 1 + digits]
233
+
234
+ def guess_extension_from_bytes(data):
235
+ """
236
+ Guess file extension from byte content using filetype library.
237
+ Returns None if unable to determine the file type.
238
+
239
+ Args:
240
+ data: bytes or BytesIO object containing file data
241
+
242
+ Returns:
243
+ str: File extension with leading dot (e.g., '.png') or None if unable to determine
244
+ """
245
+ import filetype
246
+ from io import BytesIO
247
+
248
+ # Ensure we have bytes
249
+ if isinstance(data, BytesIO):
250
+ data = data.getvalue()
251
+ elif hasattr(data, 'read'):
252
+ data = data.read()
253
+
254
+ if not data or len(data) == 0:
255
+ return None
256
+
257
+ kind = filetype.guess(data)
258
+ if kind is not None:
259
+ return f'.{kind.extension}'
260
+
261
+ return None
262
+
263
+ def _uploaded_filename(source, data, name=None):
264
+ if name is None:
265
+ if isinstance(source, str):
266
+ name = os.path.basename(source)
267
+ elif hasattr(source, 'name') and source.name:
268
+ name = source.name
269
+ else:
270
+ name = f"uploaded_file_{short_id()}"
271
+
272
+ _, name_ext = os.path.splitext(name)
273
+ if name_ext:
274
+ extension = name_ext
275
+ elif isinstance(source, str):
276
+ extension = os.path.splitext(source)[1]
277
+ else:
278
+ extension = guess_extension_from_bytes(data) or ''
279
+
280
+ return os.path.splitext(name)[0] + extension
281
+
282
+ def upload_file_to_folder(source, folder, name=None):
283
+ """Copy or write a file-like source into folder and return its absolute path."""
284
+ if source is None:
285
+ raise ValueError("A file source must be provided")
286
+
287
+ os.makedirs(folder, exist_ok=True)
288
+
289
+ if isinstance(source, str):
290
+ if not os.path.exists(source):
291
+ raise ValueError(f"File not found: {source}")
292
+ data = None
293
+ filename = _uploaded_filename(source, data, name=name)
294
+ dest_path = os.path.join(folder, filename)
295
+ shutil.copy2(source, dest_path)
296
+ elif isinstance(source, BytesIO):
297
+ data = source.getvalue()
298
+ filename = _uploaded_filename(source, data, name=name)
299
+ dest_path = os.path.join(folder, filename)
300
+ with open(dest_path, 'wb') as f:
301
+ f.write(data)
302
+ elif isinstance(source, bytes):
303
+ data = source
304
+ filename = _uploaded_filename(source, data, name=name)
305
+ dest_path = os.path.join(folder, filename)
306
+ with open(dest_path, 'wb') as f:
307
+ f.write(data)
308
+ else:
309
+ raise TypeError(f"Unsupported source type: {type(source)}. Expected str, BytesIO, or bytes.")
310
+
311
+ return os.path.abspath(dest_path)
312
+
313
+ class Logger:
314
+
315
+ def __init__(self,file=None):
316
+ self.file=file or root_join('logs',"log.txt")
317
+ open(self.file,'w', encoding='utf-8').close()
318
+
319
+ def log(self,message):
320
+ with open(self.file,'a',encoding="utf-8") as f:
321
+ f.write(str(message)+'\n')
322
+
323
+ def log_to_file(self,content,file=None):
324
+ file = file or self.file
325
+ with open(file,'w',encoding="utf-8") as f:
326
+ f.write(str(content)+'\n')
327
+
328
+ def read_document_content(source, start_at_line=1, max_tokens=8000):
329
+ """
330
+ Read and extract text content from various document formats.
331
+
332
+ Args:
333
+ source: Document source - either a local file path or URL (http/https)
334
+ start_at_line: Line number to start reading from (1-indexed)
335
+ max_tokens: Maximum tokens for truncation (0 to disable)
336
+
337
+ Returns:
338
+ Extracted text content, possibly truncated
339
+ """
340
+ get_text = import_module(".get_text.get_text", __package__).get_text
341
+
342
+ text=get_text(source)
343
+
344
+ # Apply truncation - truncate function handles smart chunking internally
345
+ if max_tokens > 0:
346
+ text = truncate(text, max_tokens=max_tokens, start_line=start_at_line)
347
+
348
+ return text
349
+
350
+ def is_running_in_streamlit_runtime():
351
+ try:
352
+ from streamlit.runtime.scriptrunner import get_script_run_ctx
353
+ except Exception:
354
+ return False
355
+
356
+ return get_script_run_ctx() is not None
357
+
358
+ class ThreadPool:
359
+ threads = {}
360
+
361
+ @classmethod
362
+ def register(cls, thread):
363
+ cls.threads[thread.name] = thread
364
+
365
+ @classmethod
366
+ def join_all(cls, timeout=None):
367
+ for thread in list(cls.threads.values()):
368
+ if thread.is_alive():
369
+ thread.join(timeout=timeout)
370
+
371
+ def Thread(*args,**kwargs):
372
+ thread = ThreadBase(*args,**kwargs)
373
+ ThreadPool.register(thread)
374
+ return thread