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,418 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ import webbrowser
5
+ from collections.abc import Mapping
6
+
7
+ from .image import persist_image_url
8
+ from .memory import format_memory_results_xml
9
+ from .tool import ImageGenerationTool, WebSearchTool, get_agent, tool
10
+ from .utils import read_document_content
11
+
12
+ web_search = WebSearchTool()
13
+ image_generation = ImageGenerationTool()
14
+
15
+
16
+ @tool
17
+ def read(source, start_at_line: int = 1):
18
+ """
19
+ description: |
20
+ Read and extract text content from various sources including folders, files, urls or python variables / objects.
21
+ This tool reads exactly one source per call. To read multiple sources at once, issue multiple `read` tool calls in parallel.
22
+ For folders: returns a tree view of the folder structure.
23
+ For files: returns the text content. PDF, DOCX, XLSX, PPTX, ODT, HTML, TXT, and more are supported.
24
+ For urls: returns the text content of the web page (or of the remote file if the url points to a file).
25
+ For python variables / objects: introspects the object or gives a str repr.
26
+
27
+ For large outputs: The output will be automatically truncated to fit token limits. When truncated, you'll see a message at the end of content indicating the truncated line range to know from where to resume reading.
28
+ parameters:
29
+ source:
30
+ description: |
31
+ Document source - can be either:
32
+ - Absolute path to a local folder or file (e.g., "/home/user/document.pdf")
33
+ - URL (e.g., "https://example.com/report.pdf")
34
+ - any other data structure, or python variable living in the shell's namespace
35
+ Pass only one source. Use parallel `read` calls for multiple sources.
36
+ start_at_line:
37
+ description: |
38
+ Line number to start reading from (1-indexed). Default is 1 (start from beginning).
39
+ When reading large documents in chunks, set this to continue from where you left off.
40
+ type: integer
41
+ required:
42
+ - source
43
+ """
44
+ agent = get_agent()
45
+ max_tokens = agent.config.get('max_input_tokens', 8000)
46
+ return read_document_content(source, start_at_line=start_at_line, max_tokens=max_tokens)
47
+
48
+
49
+ @tool(type="custom")
50
+ def python(content=None):
51
+ """
52
+ Runs Python code in the interactive shell. Pass raw Python source code,
53
+ not JSON, quotes, or a markdown code fence.
54
+ """
55
+ agent = get_agent()
56
+ if agent.shell is not None and content:
57
+ response = agent.shell.run(content)
58
+ if response.exception:
59
+ exception = response.exception
60
+ return f"**{type(exception).__name__}**: {str(exception)}\n```\n{exception.enriched_traceback_string}\n```"
61
+
62
+ output = ""
63
+ if response.stdout:
64
+ output += f"stdout:\n{response.stdout}"
65
+ if response.result:
66
+ output += f"result:\n{str(response.result)}"
67
+ return output
68
+
69
+ return "Shell not initialized. Cannot run code."
70
+
71
+
72
+ @tool(type="custom")
73
+ def bash(content=None):
74
+ """
75
+ Runs Bash commands on the local system. Pass raw shell script text,
76
+ not JSON, quotes, or a markdown code fence.
77
+ """
78
+ if not content:
79
+ return "No bash command provided."
80
+
81
+ response = subprocess.run(
82
+ str(content),
83
+ shell=True,
84
+ executable="/bin/bash",
85
+ text=True,
86
+ capture_output=True,
87
+ cwd=os.getcwd(),
88
+ )
89
+ output = ""
90
+ if response.stdout:
91
+ output += f"stdout:\n{response.stdout}"
92
+ if response.stderr:
93
+ output += f"stderr:\n{response.stderr}"
94
+ if response.returncode:
95
+ output += f"exit_code: {response.returncode}"
96
+ return output or "Command completed with no output."
97
+
98
+
99
+ @tool
100
+ def write(writes):
101
+ """
102
+ description: |
103
+ Write or overwrite one or more complete UTF-8 text files.
104
+ Use this when creating new text files or replacing entire files is clearer than targeted edits.
105
+ Parent directories are created automatically.
106
+ Pass either a single write object or a list of write objects.
107
+ parameters:
108
+ writes:
109
+ description: |
110
+ A single write object or a list of write objects.
111
+ Each object must contain file_path and content.
112
+ anyOf:
113
+ - type: object
114
+ properties:
115
+ file_path:
116
+ type: string
117
+ content:
118
+ type: string
119
+ required:
120
+ - file_path
121
+ - content
122
+ - type: array
123
+ items:
124
+ type: object
125
+ properties:
126
+ file_path:
127
+ type: string
128
+ content:
129
+ type: string
130
+ required:
131
+ - file_path
132
+ - content
133
+ """
134
+ if isinstance(writes, Mapping):
135
+ writes = [writes]
136
+ elif not isinstance(writes, list):
137
+ return "Failed: writes must be an object or a list of objects."
138
+
139
+ reports = []
140
+ for index, item in enumerate(writes, start=1):
141
+ reports.append(apply_file_write(index, item))
142
+ return "\n".join(reports)
143
+
144
+
145
+ def apply_file_write(index, item):
146
+ if not isinstance(item, Mapping):
147
+ return f"{index}. failed: write must be an object."
148
+
149
+ file_path = item.get("file_path")
150
+ content = item.get("content")
151
+ if not file_path:
152
+ return f"{index}. failed: file_path must not be empty."
153
+ if content is None:
154
+ return f"{index}. failed: content must not be null."
155
+
156
+ path = os.path.abspath(os.path.expanduser(str(file_path)))
157
+ try:
158
+ parent = os.path.dirname(path)
159
+ if parent:
160
+ os.makedirs(parent, exist_ok=True)
161
+ with open(path, "w", encoding="utf-8") as f:
162
+ f.write(str(content))
163
+ except OSError as exc:
164
+ return f"{index}. failed: could not write {path}: {exc}"
165
+
166
+ return f"{index}. success: wrote {len(str(content))} characters to {path}."
167
+
168
+
169
+ @tool
170
+ def edit(edits):
171
+ """
172
+ description: |
173
+ Perform targeted exact-string replacements in local text files.
174
+ Pass either a single edit object or a list of edit objects.
175
+ Each edit object must contain:
176
+ - file_path: path to the file to edit
177
+ - old_string: exact text to replace
178
+ - new_string: replacement text
179
+ - replace_all: optional boolean, default false
180
+
181
+ By default old_string must match exactly once in the file. If it matches
182
+ zero times or more than once, that edit fails and the file is left unchanged.
183
+ Set replace_all=true to replace every match.
184
+ Multiple edits are tried in sequence; later edits see the result of earlier successful edits.
185
+ parameters:
186
+ edits:
187
+ description: A single edit object or a list of edit objects.
188
+ required:
189
+ - edits
190
+ """
191
+ if isinstance(edits, Mapping):
192
+ edit_items = [edits]
193
+ elif isinstance(edits, list):
194
+ edit_items = edits
195
+ else:
196
+ return "Failed: edits must be an object or a list of objects."
197
+
198
+ reports = []
199
+ for index, item in enumerate(edit_items, start=1):
200
+ reports.append(apply_text_edit(index, item))
201
+ return "\n".join(reports)
202
+
203
+
204
+ def apply_text_edit(index, item):
205
+ if not isinstance(item, Mapping):
206
+ return f"{index}. failed: edit must be an object."
207
+
208
+ file_path = item.get("file_path")
209
+ old_string = item.get("old_string")
210
+ new_string = item.get("new_string")
211
+ replace_all = bool(item.get("replace_all", False))
212
+
213
+ missing = [
214
+ key for key, value in (
215
+ ("file_path", file_path),
216
+ ("old_string", old_string),
217
+ ("new_string", new_string),
218
+ )
219
+ if value is None
220
+ ]
221
+ if missing:
222
+ return f"{index}. failed: missing {', '.join(missing)}."
223
+ if old_string == "":
224
+ return f"{index}. failed: old_string must not be empty."
225
+
226
+ path = os.path.abspath(os.path.expanduser(str(file_path)))
227
+ if not os.path.isfile(path):
228
+ return f"{index}. failed: file not found: {path}"
229
+
230
+ try:
231
+ with open(path, "r", encoding="utf-8") as f:
232
+ content = f.read()
233
+ except UnicodeDecodeError:
234
+ return f"{index}. failed: file is not valid UTF-8 text: {path}"
235
+ except OSError as exc:
236
+ return f"{index}. failed: could not read {path}: {exc}"
237
+
238
+ old_string = str(old_string)
239
+ new_string = str(new_string)
240
+ matches = content.count(old_string)
241
+ if matches == 0:
242
+ return f"{index}. failed: old_string not found in {path}."
243
+ if matches > 1 and not replace_all:
244
+ return f"{index}. failed: old_string matched {matches} times in {path}; set replace_all=true to replace all matches."
245
+
246
+ updated = content.replace(old_string, new_string) if replace_all else content.replace(old_string, new_string, 1)
247
+ try:
248
+ with open(path, "w", encoding="utf-8") as f:
249
+ f.write(updated)
250
+ except OSError as exc:
251
+ return f"{index}. failed: could not write {path}: {exc}"
252
+
253
+ replaced = matches if replace_all else 1
254
+ return f"{index}. success: replaced {replaced} match{'es' if replaced != 1 else ''} in {path}."
255
+
256
+
257
+ @tool
258
+ def memory_add(content: str, title: str = ""):
259
+ """
260
+ description: |
261
+ Create a durable memory entry in the agent's runtime memory store.
262
+ Use this only for information that should be remembered beyond the current session.
263
+ parameters:
264
+ content:
265
+ description: The memory text to store.
266
+ type: string
267
+ title:
268
+ description: Optional title that can later be used instead of the memory id.
269
+ type: string
270
+ required:
271
+ - content
272
+ """
273
+ entry = get_agent().memory.remember(content=content, title=title)
274
+ return f"Created memory {entry.id}: {entry.content}"
275
+
276
+
277
+ @tool
278
+ def memory_edit(memory_id: str, content: str):
279
+ """
280
+ description: |
281
+ Edit an existing durable memory entry by id.
282
+ The memory_id argument can be either the memory id or its title. Editing content refreshes the embedding.
283
+ parameters:
284
+ memory_id:
285
+ description: The id or title of the memory entry to edit.
286
+ type: string
287
+ content:
288
+ description: Replacement memory text.
289
+ type: string
290
+ required:
291
+ - memory_id
292
+ - content
293
+ """
294
+ entry = get_agent().memory.edit(memory_id, content=content)
295
+ return f"Updated memory {entry.id}: {entry.content}"
296
+
297
+
298
+ @tool
299
+ def memory_delete(memory_id: str):
300
+ """
301
+ description: |
302
+ Delete a durable memory entry by id.
303
+ The memory_id argument can be either the memory id or its title.
304
+ parameters:
305
+ memory_id:
306
+ description: The id or title of the memory entry to delete.
307
+ type: string
308
+ required:
309
+ - memory_id
310
+ """
311
+ entry = get_agent().memory.delete(memory_id)
312
+ if entry is None:
313
+ return f"Memory not found: {memory_id}"
314
+ return f"Deleted memory {entry.id}: {entry.content}"
315
+
316
+
317
+ @tool
318
+ def memory_search(query: str, limit: int = 5, recent_is_better: bool = False):
319
+ """
320
+ description: |
321
+ Search durable memory entries by semantic similarity to a text query.
322
+ This does not create a new memory entry.
323
+ parameters:
324
+ query:
325
+ description: Search query text.
326
+ type: string
327
+ limit:
328
+ description: Maximum number of memory entries to return.
329
+ type: integer
330
+ recent_is_better:
331
+ description: Whether to add the temporal recency score to semantic relevance (favors more recent).
332
+ type: boolean
333
+ required:
334
+ - query
335
+ """
336
+ agent = get_agent()
337
+ results = agent.memory.search(
338
+ query,
339
+ limit=limit,
340
+ recent_is_better=recent_is_better,
341
+ until=agent.memory_retrieval_until_timestamp(),
342
+ )
343
+ if not results:
344
+ return "No memories found."
345
+ return format_memory_results_xml(results)
346
+
347
+
348
+ @tool
349
+ def observe(source: str):
350
+ """
351
+ description: |
352
+ Observe and analyze an image by adding it to the conversation context.
353
+ The image will be available for visual analysis in the next response.
354
+ Supports local file paths and URLs (http/https).
355
+
356
+ Use this tool when you need to see and analyze images, screenshots, diagrams, charts, photos, or any visual content.
357
+ parameters:
358
+ source:
359
+ description: |
360
+ Image source - can be either:
361
+ - Absolute file path to a local image file (e.g., "/home/user/image.png")
362
+ - URL to an image (e.g., "https://example.com/image.jpg")
363
+ type: string
364
+ required:
365
+ - source
366
+ """
367
+ original_source = source
368
+ if isinstance(source, str) and source.startswith(("http://", "https://")):
369
+ source = persist_image_url(source)
370
+ get_agent().add_image(source=source, pending=True, image_name=original_source)
371
+ return (
372
+ f"Image from '{original_source}' has been downloaded, cached at '{source}', "
373
+ "and is now visible for analysis."
374
+ )
375
+
376
+ get_agent().add_image(source=source, pending=True)
377
+ return f"Image from '{source}' has been loaded and is now visible for analysis."
378
+
379
+
380
+ @tool
381
+ def show(file_or_url: str):
382
+ """
383
+ description: |
384
+ Open a local file, folder, or URL with the system default application/browser.
385
+ Use this tool when the user wants to visually inspect a source outside the chat.
386
+ parameters:
387
+ file_or_url:
388
+ description: Absolute or relative local path, folder path, file URI, or HTTP(S) URL to open.
389
+ type: string
390
+ required:
391
+ - file_or_url
392
+ """
393
+ target = str(file_or_url)
394
+ if not target.startswith(("http://", "https://", "file://")):
395
+ target = os.path.abspath(os.path.expanduser(target))
396
+
397
+ opened = open_silently(target)
398
+ if opened:
399
+ return f"Opened: {target}"
400
+ return f"Could not open: {target}"
401
+
402
+
403
+ def open_silently(target: str) -> bool:
404
+ if os.name == "posix":
405
+ opener = "open" if sys.platform == "darwin" else "xdg-open"
406
+ try:
407
+ subprocess.Popen(
408
+ [opener, target],
409
+ stdout=subprocess.DEVNULL,
410
+ stderr=subprocess.DEVNULL,
411
+ stdin=subprocess.DEVNULL,
412
+ start_new_session=True,
413
+ )
414
+ return True
415
+ except FileNotFoundError:
416
+ pass
417
+
418
+ return webbrowser.open(target)
codex_agent/chat.py ADDED
@@ -0,0 +1,139 @@
1
+ from prompt_toolkit import PromptSession
2
+ from rich.console import Console
3
+ from rich.panel import Panel
4
+ from rich.rule import Rule
5
+ from rich.text import Text
6
+ from .event import ResponseContentDeltaEvent, ResponseDoneEvent, ResponseStartEvent, ServerToolCallEvent, ToolCallStartEvent
7
+
8
+
9
+ class TerminalChat:
10
+ """Small terminal UI wired to agent events."""
11
+
12
+ def __init__(self, agent, console=None):
13
+ self.agent = agent
14
+ self.console = console or Console()
15
+ self.prompt_session = PromptSession(multiline=True)
16
+ self._in_response = False
17
+ self._has_content = False
18
+ self._seen_tool_calls = set()
19
+
20
+ def attach(self):
21
+ self.agent.on(ResponseStartEvent, self._on_response_start)
22
+ self.agent.on(ResponseContentDeltaEvent, self._on_content_delta)
23
+ self.agent.on(ToolCallStartEvent, self._on_tool_call_start)
24
+ self.agent.on(ServerToolCallEvent, self._on_server_tool_call)
25
+ self.agent.on(ResponseDoneEvent, self._on_response_done)
26
+ return self
27
+
28
+ def run(self):
29
+ self.attach()
30
+ self.console.print(Panel.fit(
31
+ "[bold]Codex Agent[/bold]\n[dim]Alt+Enter submits multiline input. Type exit or quit to stop.[/dim]",
32
+ border_style="cyan",
33
+ ))
34
+ while True:
35
+ try:
36
+ self.print_status_bar()
37
+ prompt = self.prompt_session.prompt(
38
+ [("class:prompt", "You > ")],
39
+ prompt_continuation="... ",
40
+ )
41
+ if prompt.strip().lower() in ["exit", "quit", "/exit", "/quit"]:
42
+ self.console.print("[dim]Bye.[/dim]")
43
+ break
44
+ if not prompt.strip():
45
+ continue
46
+ try:
47
+ result = self.agent(prompt)
48
+ if isinstance(result, str) and result:
49
+ self.console.print(result, style="dim")
50
+ except KeyboardInterrupt:
51
+ self.agent.interrupt("keyboard_interrupt")
52
+ self.console.print("\n[dim]Interrupted.[/dim]")
53
+ self.console.print()
54
+ except (KeyboardInterrupt, EOFError):
55
+ self.console.print("\n[dim]Bye.[/dim]")
56
+ break
57
+
58
+ def print_status_bar(self):
59
+ try:
60
+ status = self.agent.context_status()
61
+ except Exception as exc:
62
+ self.console.print(f"[dim red]status unavailable: {exc}[/dim red]")
63
+ return
64
+
65
+ percent = status.percent
66
+ style = "green"
67
+ if percent >= 90:
68
+ style = "bold red"
69
+ elif percent >= 75:
70
+ style = "yellow"
71
+ elif percent >= 50:
72
+ style = "cyan"
73
+
74
+ used = self._format_int(status.used_tokens)
75
+ limit = self._format_int(status.input_token_limit)
76
+ session = str(status.session_id or "-")[-15:]
77
+ compact_hint = " auto-compact:on" if status.auto_compact else " auto-compact:off"
78
+ if status.auto_compact and status.used_tokens >= status.auto_compact_threshold:
79
+ compact_hint = " auto-compact:due"
80
+
81
+ self.console.print(
82
+ Text.assemble(
83
+ ("ctx ", "dim"),
84
+ (f"{percent:.1f}%", style),
85
+ (f" {used}/{limit} tok", "dim"),
86
+ (f" · hist {status.visible_history_messages}/{status.total_history_messages}", "dim"),
87
+ (f" · msgs {status.total_session_messages}", "dim"),
88
+ (f" · img {status.image_messages}", "dim"),
89
+ (f" · mem {status.memory_entries}", "dim"),
90
+ (f" · pending {status.pending_messages}", "dim"),
91
+ (f" · {session}", "dim"),
92
+ (compact_hint, "dim"),
93
+ )
94
+ )
95
+
96
+ @staticmethod
97
+ def _format_int(value):
98
+ try:
99
+ return f"{int(value):,}".replace(",", " ")
100
+ except Exception:
101
+ return str(value)
102
+
103
+ def _on_response_start(self, event):
104
+ if self._in_response:
105
+ self.console.print()
106
+ self._in_response = True
107
+ self._has_content = False
108
+ self._seen_tool_calls.clear()
109
+ name = event.message.get("name") if event.get("message") else None
110
+ self.console.print(Rule(Text(name or "Agent", style="bold cyan"), style="cyan"))
111
+
112
+ def _on_content_delta(self, event):
113
+ self._has_content = True
114
+ self.console.print(event.delta, end="", markup=False, highlight=False, soft_wrap=True)
115
+
116
+ def _on_tool_call_start(self, event):
117
+ tool_call = event.get("tool_call") or {}
118
+ name = tool_call.get("name") or "unknown"
119
+ call_id = tool_call.get("call_id") or name
120
+ self._print_tool_call(name, call_id)
121
+
122
+ def _on_server_tool_call(self, event):
123
+ name = event.get("name") or "unknown"
124
+ call_id = event.get("call_id") or name
125
+ self._print_tool_call(name, call_id)
126
+
127
+ def _print_tool_call(self, name, call_id):
128
+ if call_id in self._seen_tool_calls:
129
+ return
130
+ self._seen_tool_calls.add(call_id)
131
+ if self._has_content:
132
+ self.console.print()
133
+ self.console.print(f"tool: {name}", style="dim cyan")
134
+ self._has_content = False
135
+
136
+ def _on_response_done(self, event):
137
+ if self._has_content:
138
+ self.console.print()
139
+ self._in_response = False
codex_agent/command.py ADDED
@@ -0,0 +1,19 @@
1
+ from modict import modict
2
+
3
+
4
+ COMMAND_MARKER = "__codex_agent_command__"
5
+
6
+
7
+ def command(func=None, **options):
8
+ """Mark a function as an agent slash command for automatic discovery."""
9
+ if func is None:
10
+ def decorator(f):
11
+ return command(f, **options)
12
+ return decorator
13
+
14
+ setattr(func, COMMAND_MARKER, modict(options))
15
+ return func
16
+
17
+
18
+ def command_options(func):
19
+ return getattr(func, COMMAND_MARKER, None)