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
|
@@ -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)
|