python-codex 0.1.3__py3-none-any.whl → 0.1.5__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.
- pycodex/agent.py +51 -11
- pycodex/cli.py +109 -3
- pycodex/context.py +23 -0
- pycodex/model.py +362 -23
- pycodex/prompts/models.json +30 -0
- pycodex/tools/apply_patch_tool.py +2 -2
- pycodex/utils/__init__.py +4 -0
- pycodex/utils/compactor.py +189 -0
- pycodex/utils/session_persist.py +483 -0
- pycodex/utils/visualize.py +120 -6
- {python_codex-0.1.3.dist-info → python_codex-0.1.5.dist-info}/METADATA +18 -3
- {python_codex-0.1.3.dist-info → python_codex-0.1.5.dist-info}/RECORD +18 -16
- responses_server/app.py +4 -1
- responses_server/payload_processors.py +10 -1
- responses_server/stream_router.py +25 -6
- {python_codex-0.1.3.dist-info → python_codex-0.1.5.dist-info}/WHEEL +0 -0
- {python_codex-0.1.3.dist-info → python_codex-0.1.5.dist-info}/entry_points.txt +0 -0
- {python_codex-0.1.3.dist-info → python_codex-0.1.5.dist-info}/licenses/LICENSE +0 -0
pycodex/agent.py
CHANGED
|
@@ -20,6 +20,9 @@ from .tools import ToolContext, ToolRegistry
|
|
|
20
20
|
from .utils import uuid7_string
|
|
21
21
|
import typing
|
|
22
22
|
|
|
23
|
+
if typing.TYPE_CHECKING:
|
|
24
|
+
from .utils.session_persist import SessionRolloutRecorder
|
|
25
|
+
|
|
23
26
|
|
|
24
27
|
EventHandler = Callable[[AgentEvent], None]
|
|
25
28
|
NOOP_EVENT_HANDLER: 'EventHandler' = lambda _event: None
|
|
@@ -46,6 +49,7 @@ class AgentLoop:
|
|
|
46
49
|
parallel_tool_calls: 'bool' = True,
|
|
47
50
|
event_handler: 'EventHandler' = NOOP_EVENT_HANDLER,
|
|
48
51
|
initial_history: 'typing.Tuple[ConversationItem, ...]' = (),
|
|
52
|
+
rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]' = None,
|
|
49
53
|
) -> 'None':
|
|
50
54
|
self._model_client = model_client
|
|
51
55
|
self._tool_registry = tool_registry
|
|
@@ -53,6 +57,7 @@ class AgentLoop:
|
|
|
53
57
|
self._parallel_tool_calls = parallel_tool_calls
|
|
54
58
|
self._event_handler = event_handler
|
|
55
59
|
self._history: 'typing.List[ConversationItem]' = list(initial_history)
|
|
60
|
+
self._rollout_recorder = rollout_recorder
|
|
56
61
|
self.interrupt_asap = False
|
|
57
62
|
|
|
58
63
|
@property
|
|
@@ -64,6 +69,18 @@ class AgentLoop:
|
|
|
64
69
|
) -> 'None':
|
|
65
70
|
self._event_handler = event_handler
|
|
66
71
|
|
|
72
|
+
def replace_history(
|
|
73
|
+
self,
|
|
74
|
+
history: 'typing.Iterable[ConversationItem]',
|
|
75
|
+
) -> 'None':
|
|
76
|
+
self._history = list(history)
|
|
77
|
+
|
|
78
|
+
def set_rollout_recorder(
|
|
79
|
+
self,
|
|
80
|
+
rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]',
|
|
81
|
+
) -> 'None':
|
|
82
|
+
self._rollout_recorder = rollout_recorder
|
|
83
|
+
|
|
67
84
|
def _raise_if_interrupt_requested(
|
|
68
85
|
self,
|
|
69
86
|
turn_id: 'str',
|
|
@@ -83,8 +100,9 @@ class AgentLoop:
|
|
|
83
100
|
) -> 'TurnResult':
|
|
84
101
|
turn_id = turn_id or uuid7_string()
|
|
85
102
|
self.interrupt_asap = False
|
|
86
|
-
for text in texts
|
|
87
|
-
|
|
103
|
+
new_user_messages = [UserMessage(text=text) for text in texts]
|
|
104
|
+
self._history.extend(new_user_messages)
|
|
105
|
+
self._persist_history_items(new_user_messages)
|
|
88
106
|
|
|
89
107
|
self._emit(
|
|
90
108
|
"turn_started",
|
|
@@ -131,12 +149,15 @@ class AgentLoop:
|
|
|
131
149
|
)
|
|
132
150
|
|
|
133
151
|
tool_calls: 'typing.List[ToolCall]' = []
|
|
152
|
+
persisted_response_items: 'typing.List[ConversationItem]' = []
|
|
134
153
|
for item in response.items:
|
|
135
154
|
self._history.append(item)
|
|
155
|
+
persisted_response_items.append(item)
|
|
136
156
|
if isinstance(item, AssistantMessage):
|
|
137
157
|
last_assistant_message = item.text
|
|
138
158
|
elif isinstance(item, ToolCall):
|
|
139
159
|
tool_calls.append(item)
|
|
160
|
+
self._persist_history_items(persisted_response_items)
|
|
140
161
|
|
|
141
162
|
if not tool_calls:
|
|
142
163
|
self._raise_if_interrupt_requested(
|
|
@@ -160,7 +181,10 @@ class AgentLoop:
|
|
|
160
181
|
|
|
161
182
|
tool_results = await self._execute_tool_batch(turn_id, tool_calls)
|
|
162
183
|
self._history.extend(tool_results)
|
|
163
|
-
self.
|
|
184
|
+
self._persist_history_items(tool_results)
|
|
185
|
+
follow_up_messages = self._build_follow_up_messages(tool_results)
|
|
186
|
+
self._history.extend(follow_up_messages)
|
|
187
|
+
self._persist_history_items(follow_up_messages)
|
|
164
188
|
self._raise_if_interrupt_requested(
|
|
165
189
|
turn_id,
|
|
166
190
|
iteration,
|
|
@@ -226,7 +250,12 @@ class AgentLoop:
|
|
|
226
250
|
call: 'ToolCall',
|
|
227
251
|
prior_results: 'typing.Tuple[ToolResult, ...]' = (),
|
|
228
252
|
) -> 'ToolResult':
|
|
229
|
-
|
|
253
|
+
payload: 'typing.Dict[str, object]' = {
|
|
254
|
+
"tool_name": call.name,
|
|
255
|
+
"call_id": call.call_id,
|
|
256
|
+
"call": call,
|
|
257
|
+
}
|
|
258
|
+
self._emit("tool_started", turn_id, **payload)
|
|
230
259
|
result = await self._tool_registry.execute(
|
|
231
260
|
call,
|
|
232
261
|
ToolContext(
|
|
@@ -235,13 +264,8 @@ class AgentLoop:
|
|
|
235
264
|
collaboration_mode=self._context_manager.collaboration_mode,
|
|
236
265
|
),
|
|
237
266
|
)
|
|
238
|
-
payload
|
|
239
|
-
|
|
240
|
-
"call_id": call.call_id,
|
|
241
|
-
"is_error": result.is_error,
|
|
242
|
-
"call": call,
|
|
243
|
-
"result": result,
|
|
244
|
-
}
|
|
267
|
+
payload["result"] = result
|
|
268
|
+
payload["is_error"] = result.is_error
|
|
245
269
|
self._emit("tool_completed", turn_id, **payload)
|
|
246
270
|
return result
|
|
247
271
|
|
|
@@ -250,11 +274,27 @@ class AgentLoop:
|
|
|
250
274
|
AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
|
|
251
275
|
)
|
|
252
276
|
|
|
277
|
+
def _persist_history_items(
|
|
278
|
+
self,
|
|
279
|
+
items: 'typing.Iterable[ConversationItem]',
|
|
280
|
+
) -> 'None':
|
|
281
|
+
recorder = self._rollout_recorder
|
|
282
|
+
if recorder is None:
|
|
283
|
+
return
|
|
284
|
+
try:
|
|
285
|
+
recorder.append_history_items(items)
|
|
286
|
+
except Exception: # pragma: no cover - persistence should not break turns
|
|
287
|
+
return
|
|
288
|
+
|
|
253
289
|
def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
|
|
254
290
|
if event.kind == "assistant_delta":
|
|
255
291
|
self._emit("assistant_delta", turn_id, **event.payload)
|
|
256
292
|
elif event.kind == "tool_call":
|
|
257
293
|
self._emit("tool_called", turn_id, **event.payload)
|
|
294
|
+
elif event.kind == "token_count":
|
|
295
|
+
self._emit("token_count", turn_id, **event.payload)
|
|
296
|
+
elif event.kind == "stream_error":
|
|
297
|
+
self._emit("stream_error", turn_id, **event.payload)
|
|
258
298
|
|
|
259
299
|
def _build_follow_up_messages(
|
|
260
300
|
self,
|
pycodex/cli.py
CHANGED
|
@@ -20,7 +20,15 @@ from .portable import bootstrap_called_home, upload_codex_home
|
|
|
20
20
|
from .protocol import AgentEvent
|
|
21
21
|
from .runtime import AgentRuntime
|
|
22
22
|
from .runtime_services import RuntimeEnvironment, create_runtime_environment
|
|
23
|
-
from .utils import CliSessionView, load_codex_dotenv
|
|
23
|
+
from .utils import CliSessionView, load_codex_dotenv, uuid7_string
|
|
24
|
+
from .utils.compactor import compact_agent_loop
|
|
25
|
+
from .utils.session_persist import (
|
|
26
|
+
SessionRolloutRecorder,
|
|
27
|
+
conversation_history_to_turns,
|
|
28
|
+
list_resumable_sessions,
|
|
29
|
+
load_resumed_session,
|
|
30
|
+
resolve_codex_home,
|
|
31
|
+
)
|
|
24
32
|
import typing
|
|
25
33
|
|
|
26
34
|
EXIT_COMMANDS = {"/exit", "/quit"}
|
|
@@ -28,6 +36,8 @@ HISTORY_COMMAND = "/history"
|
|
|
28
36
|
TITLE_COMMAND = "/title"
|
|
29
37
|
MODEL_COMMAND = "/model"
|
|
30
38
|
QUEUE_COMMAND = "/queue"
|
|
39
|
+
RESUME_COMMAND = "/resume"
|
|
40
|
+
COMPACT_COMMAND = "/compact"
|
|
31
41
|
CliSessionMode = Literal["exec", "tui"]
|
|
32
42
|
LOCAL_RESPONSES_SERVER_API_KEY_ENV = "PYCODEX_LOCAL_RESPONSES_SERVER_KEY"
|
|
33
43
|
CLI_ORIGINATOR = "codex-tui"
|
|
@@ -284,6 +294,9 @@ def build_runtime(
|
|
|
284
294
|
collaboration_mode=collaboration_mode,
|
|
285
295
|
include_collaboration_instructions=use_tui_context,
|
|
286
296
|
)
|
|
297
|
+
session_id = getattr(client, "_session_id", None) or uuid7_string()
|
|
298
|
+
if hasattr(client, "_session_id"):
|
|
299
|
+
client._session_id = session_id
|
|
287
300
|
subagent_context_manager = ContextManager.from_codex_config(
|
|
288
301
|
config_path,
|
|
289
302
|
profile,
|
|
@@ -293,6 +306,14 @@ def build_runtime(
|
|
|
293
306
|
runtime_environment = create_runtime_environment()
|
|
294
307
|
runtime_environment.request_user_input_manager.set_handler(None)
|
|
295
308
|
runtime_environment.request_permissions_manager.set_handler(None)
|
|
309
|
+
rollout_recorder = SessionRolloutRecorder.create(
|
|
310
|
+
resolve_codex_home(config_path),
|
|
311
|
+
session_id,
|
|
312
|
+
context_manager.cwd,
|
|
313
|
+
getattr(client, "_originator", CLI_ORIGINATOR),
|
|
314
|
+
getattr(getattr(client, "_config", None), "provider_name", None),
|
|
315
|
+
context_manager.resolve_base_instructions(),
|
|
316
|
+
)
|
|
296
317
|
|
|
297
318
|
def make_subagent_runtime_builder(base_client):
|
|
298
319
|
def build_subagent_runtime(
|
|
@@ -330,7 +351,10 @@ def build_runtime(
|
|
|
330
351
|
)
|
|
331
352
|
return AgentRuntime(
|
|
332
353
|
AgentLoop(
|
|
333
|
-
client,
|
|
354
|
+
client,
|
|
355
|
+
get_tools(runtime_environment, exec_mode=True),
|
|
356
|
+
context_manager,
|
|
357
|
+
rollout_recorder=rollout_recorder,
|
|
334
358
|
),
|
|
335
359
|
runtime_environment=runtime_environment,
|
|
336
360
|
)
|
|
@@ -498,10 +522,14 @@ async def prompt_request_permissions(
|
|
|
498
522
|
async def run_interactive_session(
|
|
499
523
|
runtime: 'AgentRuntime',
|
|
500
524
|
json_mode: 'bool',
|
|
525
|
+
config_path: 'typing.Union[str, None]' = None,
|
|
501
526
|
) -> 'int':
|
|
502
527
|
worker = asyncio.create_task(runtime.run_forever())
|
|
528
|
+
context_window_tokens = runtime._agent_loop._context_manager.resolve_model_context_window()
|
|
503
529
|
view = CliSessionView()
|
|
530
|
+
view.set_context_window_tokens(context_window_tokens)
|
|
504
531
|
model_client = runtime._agent_loop._model_client
|
|
532
|
+
codex_home = resolve_codex_home(config_path)
|
|
505
533
|
runtime.set_event_handler(view.handle_event)
|
|
506
534
|
pending_turn_tasks: 'typing.Set[asyncio.Task[None]]' = set()
|
|
507
535
|
runtime_environment = runtime.runtime_environment
|
|
@@ -515,7 +543,7 @@ async def run_interactive_session(
|
|
|
515
543
|
lambda payload: prompt_request_permissions(view, payload)
|
|
516
544
|
)
|
|
517
545
|
view.write_line("pycodex interactive mode. Type /exit to quit.")
|
|
518
|
-
view.write_line("Extra commands: /history, /title, /model")
|
|
546
|
+
view.write_line("Extra commands: /history, /title, /model, /resume, /compact")
|
|
519
547
|
try:
|
|
520
548
|
|
|
521
549
|
def has_pending_turn_tasks() -> 'bool':
|
|
@@ -524,6 +552,39 @@ async def run_interactive_session(
|
|
|
524
552
|
)
|
|
525
553
|
return bool(pending_turn_tasks)
|
|
526
554
|
|
|
555
|
+
async def run_manual_compact() -> 'None':
|
|
556
|
+
agent_loop = runtime._agent_loop
|
|
557
|
+
if not agent_loop.history:
|
|
558
|
+
view.write_line("Nothing to compact.")
|
|
559
|
+
return
|
|
560
|
+
|
|
561
|
+
compact_turn_id = uuid7_string()
|
|
562
|
+
|
|
563
|
+
def handle_compact_stream_event(event) -> 'None':
|
|
564
|
+
if event.kind not in {"token_count", "stream_error"}:
|
|
565
|
+
return
|
|
566
|
+
view.handle_event(
|
|
567
|
+
AgentEvent(
|
|
568
|
+
kind=event.kind,
|
|
569
|
+
turn_id=compact_turn_id,
|
|
570
|
+
payload=dict(event.payload),
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
view.write_line("Compacting conversation history...")
|
|
575
|
+
compact_result = await compact_agent_loop(
|
|
576
|
+
agent_loop,
|
|
577
|
+
handle_compact_stream_event,
|
|
578
|
+
)
|
|
579
|
+
if compact_result is None:
|
|
580
|
+
view.write_line("Nothing to compact.")
|
|
581
|
+
return
|
|
582
|
+
view.load_session_history(
|
|
583
|
+
getattr(view, "_title", None),
|
|
584
|
+
conversation_history_to_turns(compact_result.history),
|
|
585
|
+
)
|
|
586
|
+
view.write_line(compact_result.display_text())
|
|
587
|
+
|
|
527
588
|
async def wait_for_turn_result(future) -> 'None':
|
|
528
589
|
try:
|
|
529
590
|
result = await future
|
|
@@ -556,6 +617,50 @@ async def run_interactive_session(
|
|
|
556
617
|
if prompt_text == TITLE_COMMAND:
|
|
557
618
|
view.show_title()
|
|
558
619
|
continue
|
|
620
|
+
if prompt_text == RESUME_COMMAND:
|
|
621
|
+
sessions = list_resumable_sessions(codex_home)
|
|
622
|
+
if not sessions:
|
|
623
|
+
view.write_line("No resumable sessions found.")
|
|
624
|
+
continue
|
|
625
|
+
view.write_line("Available sessions:")
|
|
626
|
+
for index, session in enumerate(sessions, start=1):
|
|
627
|
+
view.write_line(f"[{index}] {session['preview']}")
|
|
628
|
+
continue
|
|
629
|
+
if prompt_text.startswith(f"{RESUME_COMMAND} "):
|
|
630
|
+
if has_pending_turn_tasks():
|
|
631
|
+
view.write_line(
|
|
632
|
+
"Cannot resume while work is running or queued."
|
|
633
|
+
)
|
|
634
|
+
continue
|
|
635
|
+
resume_target = prompt_text[len(RESUME_COMMAND) :].strip()
|
|
636
|
+
try:
|
|
637
|
+
resumed = load_resumed_session(codex_home, resume_target)
|
|
638
|
+
runtime._agent_loop.replace_history(resumed["history"])
|
|
639
|
+
if hasattr(model_client, "_session_id"):
|
|
640
|
+
model_client._session_id = str(resumed["session_id"])
|
|
641
|
+
runtime._agent_loop.set_rollout_recorder(
|
|
642
|
+
SessionRolloutRecorder.resume(resumed["rollout_path"])
|
|
643
|
+
)
|
|
644
|
+
view.load_session_history(
|
|
645
|
+
str(resumed["title"]),
|
|
646
|
+
tuple(resumed["turns"]),
|
|
647
|
+
)
|
|
648
|
+
view.write_line(f"Resumed session: {resumed['title']}")
|
|
649
|
+
view.show_history()
|
|
650
|
+
except Exception as exc: # pragma: no cover - defensive surface
|
|
651
|
+
view.show_error(str(exc))
|
|
652
|
+
continue
|
|
653
|
+
if prompt_text == COMPACT_COMMAND:
|
|
654
|
+
if has_pending_turn_tasks():
|
|
655
|
+
view.write_line(
|
|
656
|
+
"Cannot compact while work is running or queued."
|
|
657
|
+
)
|
|
658
|
+
continue
|
|
659
|
+
try:
|
|
660
|
+
await run_manual_compact()
|
|
661
|
+
except Exception as exc: # pragma: no cover - defensive surface
|
|
662
|
+
view.show_error(str(exc))
|
|
663
|
+
continue
|
|
559
664
|
if prompt_text.startswith(f"{QUEUE_COMMAND} "):
|
|
560
665
|
queued_text = prompt_text[len(QUEUE_COMMAND) :].strip()
|
|
561
666
|
if not queued_text:
|
|
@@ -663,6 +768,7 @@ async def run_cli(args: 'argparse.Namespace') -> 'int':
|
|
|
663
768
|
return await run_interactive_session(
|
|
664
769
|
runtime,
|
|
665
770
|
args.json,
|
|
771
|
+
args.config,
|
|
666
772
|
)
|
|
667
773
|
else:
|
|
668
774
|
prompt_text = resolve_prompt_text(args.prompt)
|
pycodex/context.py
CHANGED
|
@@ -30,6 +30,7 @@ DEFAULT_COLLABORATION_INSTRUCTIONS_PATH = (
|
|
|
30
30
|
PLAN_COLLABORATION_INSTRUCTIONS_PATH = (
|
|
31
31
|
Path(__file__).resolve().parent / "prompts" / "collaboration_plan.md"
|
|
32
32
|
)
|
|
33
|
+
DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 95
|
|
33
34
|
PERMISSIONS_SANDBOX_PROMPTS_PATH = (
|
|
34
35
|
Path(__file__).resolve().parent / "prompts" / "permissions" / "sandbox_mode"
|
|
35
36
|
)
|
|
@@ -76,6 +77,7 @@ class ContextConfig:
|
|
|
76
77
|
codex_home: 'typing.Union[Path, None]' = None
|
|
77
78
|
project_doc_max_bytes: 'typing.Union[int, None]' = None
|
|
78
79
|
model: 'typing.Union[str, None]' = None
|
|
80
|
+
model_context_window: 'typing.Union[int, None]' = None
|
|
79
81
|
personality: 'typing.Union[str, None]' = None
|
|
80
82
|
approval_policy: 'typing.Union[str, None]' = None
|
|
81
83
|
sandbox_mode: 'typing.Union[str, None]' = None
|
|
@@ -117,6 +119,7 @@ class ContextConfig:
|
|
|
117
119
|
codex_home=codex_home,
|
|
118
120
|
project_doc_max_bytes=_normalize_int(selected.get("project_doc_max_bytes")),
|
|
119
121
|
model=_normalize_text(selected.get("model")),
|
|
122
|
+
model_context_window=_normalize_int(selected.get("model_context_window")),
|
|
120
123
|
personality=_normalize_text(selected.get("personality")),
|
|
121
124
|
approval_policy=_normalize_text(selected.get("approval_policy")),
|
|
122
125
|
sandbox_mode=_normalize_text(selected.get("sandbox_mode")),
|
|
@@ -240,6 +243,26 @@ class ContextManager:
|
|
|
240
243
|
return resolved
|
|
241
244
|
return self._default_base_instructions
|
|
242
245
|
|
|
246
|
+
def resolve_model_context_window(self) -> 'typing.Union[int, None]':
|
|
247
|
+
model_metadata = None
|
|
248
|
+
model_slug = self._config.model
|
|
249
|
+
if model_slug is not None:
|
|
250
|
+
model_metadata = _load_models_by_slug().get(model_slug)
|
|
251
|
+
|
|
252
|
+
context_window = self._config.model_context_window
|
|
253
|
+
if context_window is None and model_metadata is not None:
|
|
254
|
+
context_window = _normalize_int(model_metadata.get("context_window"))
|
|
255
|
+
if context_window is None:
|
|
256
|
+
return None
|
|
257
|
+
effective_percent = None
|
|
258
|
+
if model_metadata is not None:
|
|
259
|
+
effective_percent = _normalize_int(
|
|
260
|
+
model_metadata.get("effective_context_window_percent")
|
|
261
|
+
)
|
|
262
|
+
if effective_percent is None:
|
|
263
|
+
effective_percent = DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT
|
|
264
|
+
return context_window * max(effective_percent, 0) // 100
|
|
265
|
+
|
|
243
266
|
def _resolve_model_instructions(self) -> 'typing.Union[str, None]':
|
|
244
267
|
model_slug = self._config.model
|
|
245
268
|
if model_slug is None:
|