python-codex 0.2.7__py3-none-any.whl → 0.3.0__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.
Files changed (84) hide show
  1. pycodex/__init__.py +14 -14
  2. pycodex/agent.py +465 -499
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -510
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +324 -253
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +76 -45
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +245 -983
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -176
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +1 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +58 -25
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +7 -5
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +41 -72
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/{image_utils.py → utils/image_utils.py} +8 -11
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +217 -163
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.0.dist-info/METADATA +704 -0
  56. python_codex-0.3.0.dist-info/RECORD +90 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +36 -31
  60. responses_server/config.py +23 -23
  61. responses_server/messages_api.py +51 -53
  62. responses_server/payload_processors.py +25 -20
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +101 -98
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +36 -14
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +461 -375
  71. workspace_server/workspace.html +852 -228
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +137 -79
  74. pycodex/collaboration.py +0 -20
  75. pycodex/interactive_session.py +0 -415
  76. pycodex/prompts/collaboration_default.md +0 -11
  77. pycodex/prompts/collaboration_plan.md +0 -128
  78. pycodex/utils/toolcall_visualize.py +0 -713
  79. pycodex/utils/visualize.py +0 -560
  80. python_codex-0.2.7.dist-info/METADATA +0 -455
  81. python_codex-0.2.7.dist-info/RECORD +0 -93
  82. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
pycodex/cli.py CHANGED
@@ -1,103 +1,31 @@
1
-
2
- import atexit
3
1
  import argparse
4
2
  import asyncio
3
+ import inspect
5
4
  import os
6
5
  import shlex
6
+ import signal
7
7
  import sys
8
8
  import tempfile
9
+ import threading
9
10
  import traceback
10
- from dataclasses import replace
11
- from pathlib import Path
12
- from typing import Sequence
13
-
14
- from .agent import Agent
15
- from .collaboration import DEFAULT_COLLABORATION_MODE, CollaborationMode
16
- from .compat import Literal
17
- from .context import ContextManager
18
- from .model import DEFAULT_CODEX_CONFIG_PATH, ResponsesModelClient, ResponsesProviderConfig
19
- from .portable import bootstrap_called_home, upload_codex_home
20
- from .runtime import CliSubmissionQueue
21
- from .runtime_services import AgentRuntimeEnvironment, create_agent_runtime_environment
22
- from .utils import CliSessionView, get_debug_dir, load_codex_dotenv, uuid7_string
23
- from .interactive_session import (
24
- EXTRA_COMMANDS_LINE,
25
- format_turn_output,
26
- run_interactive_session as _run_interactive_session,
27
- prompt_request_permissions,
28
- prompt_request_user_input,
29
- )
30
- from .utils.session_persist import (
31
- SessionRolloutRecorder,
32
- resolve_codex_home,
33
- )
34
11
  import typing
12
+ from contextlib import contextmanager
35
13
 
36
- CliSessionMode = Literal["exec", "tui"]
37
- LOCAL_RESPONSES_SERVER_API_KEY_ENV = "PYCODEX_LOCAL_RESPONSES_SERVER_KEY"
38
- CLI_ORIGINATOR = "codex-tui"
39
-
40
-
41
- def launch_chat_completion_compat_server(*args, **kwargs):
42
- from responses_server import (
43
- launch_chat_completion_compat_server as launch_compat_server,
44
- )
45
-
46
- return launch_compat_server(*args, **kwargs)
47
-
48
-
49
- def _resolve_vllm_model(
50
- endpoint: 'str',
51
- provider_config: 'ResponsesProviderConfig',
52
- timeout_seconds: 'float',
53
- ) -> 'str':
54
- from responses_server import CompatServerConfig
55
-
56
- normalized = CompatServerConfig.from_base_url(endpoint)
57
- probe_config = replace(
58
- provider_config,
59
- provider_name="vllm",
60
- base_url=normalized.outcomming_base_url,
61
- api_key_env=None,
62
- query_params={},
63
- responses_lite_override=False,
64
- )
65
- probe_client = ResponsesModelClient(
66
- probe_config,
67
- timeout_seconds,
68
- originator=CLI_ORIGINATOR,
69
- )
70
- models = probe_client.list_models_sync()
71
- if not models:
72
- raise RuntimeError(
73
- "vLLM endpoint returned no models from "
74
- f"{normalized.outcomming_models_url()}"
75
- )
76
- return models[-1]
77
-
78
-
79
- def configure_loguru() -> 'None':
80
- try:
81
- from loguru import logger
82
- except ImportError: # pragma: no cover - dependency may be absent in minimal envs
83
- return
84
-
85
- logger.remove()
86
- debug_dir = get_debug_dir()
87
- if debug_dir is not None:
88
- logger.add(str(debug_dir / "loguru.log"), level="DEBUG")
89
- return
14
+ from prompt_toolkit import PromptSession
15
+ from prompt_toolkit.enums import DEFAULT_BUFFER
16
+ from prompt_toolkit.filters import has_focus
17
+ from prompt_toolkit.key_binding import KeyBindings
18
+ from prompt_toolkit.patch_stdout import patch_stdout
90
19
 
91
- if os.environ.get("PYCODEX_DEBUG_STDERR", "").strip().lower() in {
92
- "1",
93
- "true",
94
- "yes",
95
- "on",
96
- }:
97
- logger.add(sys.stderr, level="DEBUG")
20
+ from .bootstrap import build_agent, build_model, build_runtime, configure_loguru
21
+ from .events import DEFAULT_MAIN_PROMPT, EventDisplay
22
+ from .model import DEFAULT_CODEX_CONFIG_PATH
23
+ from .portable import bootstrap_called_home, upload_codex_home
24
+ from .utils import get_debug_dir
25
+ from .utils.event_helpers import format_error, render_result
98
26
 
99
27
 
100
- def build_parser() -> 'argparse.ArgumentParser':
28
+ def build_parser():
101
29
  parser = argparse.ArgumentParser(
102
30
  prog="pycodex",
103
31
  description="Minimal Codex-style local CLI backed by ~/.codex/config.toml.",
@@ -109,17 +37,12 @@ def build_parser() -> 'argparse.ArgumentParser':
109
37
  "--put",
110
38
  default=None,
111
39
  metavar="PATH@SERVER",
112
- help=(
113
- "Upload a Codex home using `--put @host:port` or "
114
- "`--put /path/.codex@host:port`."
115
- ),
40
+ help="Upload a Codex home using `--put @host:port` or `--put /path/.codex@host:port`.",
116
41
  )
117
42
  parser.add_argument(
118
43
  "--call",
119
44
  default=None,
120
- help=(
121
- "Download and use a stored Codex home via <secret>-<call_id>@<host:port>."
122
- ),
45
+ help="Download and use a stored Codex home via <secret>-<call_id>@<host:port>.",
123
46
  )
124
47
  parser.add_argument(
125
48
  "--config",
@@ -127,35 +50,24 @@ def build_parser() -> 'argparse.ArgumentParser':
127
50
  help="Path to Codex config.toml.",
128
51
  )
129
52
  parser.add_argument(
130
- "--profile",
131
- default=None,
132
- help="Optional profile name from config.toml.",
53
+ "--profile", default=None, help="Optional profile name from config.toml."
133
54
  )
134
55
  parser.add_argument(
135
56
  "--vllm-endpoint",
136
57
  default=None,
137
- help=(
138
- "Optional base URL for a chat-completions-backed vLLM server. "
139
- "When set, pycodex starts a local responses compat server for this "
140
- "session and appends /v1 if the path is empty."
141
- ),
58
+ help="Start a local responses compat server for a chat-completions-backed vLLM endpoint.",
142
59
  )
143
60
  parser.add_argument(
144
61
  "--use-chat-completion",
145
62
  default=False,
146
63
  action="store_true",
147
- help=(
148
- "When set, pycodex starts a local responses compat server for this session."
149
- ),
64
+ help="Start a local responses compat server for this session.",
150
65
  )
151
66
  parser.add_argument(
152
67
  "--use-messages",
153
68
  default=False,
154
69
  action="store_true",
155
- help=(
156
- "When set, pycodex starts a local responses compat server and routes "
157
- "to a downstream /v1/messages backend for this session."
158
- ),
70
+ help="Route the local responses compat server to a downstream /v1/messages backend.",
159
71
  )
160
72
  parser.add_argument(
161
73
  "--system-prompt",
@@ -169,366 +81,39 @@ def build_parser() -> 'argparse.ArgumentParser':
169
81
  help="HTTP timeout for one model call.",
170
82
  )
171
83
  parser.add_argument(
172
- "--json",
173
- action="store_true",
174
- help="Print the full TurnResult as JSON.",
84
+ "--json", action="store_true", help="Print the full TurnResult as JSON."
175
85
  )
176
86
  return parser
177
87
 
178
88
 
179
- def should_run_interactive(prompt_parts: 'Sequence[str]', stdin_is_tty: 'bool') -> 'bool':
89
+ def should_run_interactive(prompt_parts, stdin_is_tty):
180
90
  return not prompt_parts and stdin_is_tty
181
91
 
182
92
 
183
- def resolve_prompt_text(prompt_parts: 'Sequence[str]') -> 'str':
93
+ def resolve_prompt_text(prompt_parts):
184
94
  if prompt_parts:
185
95
  return " ".join(prompt_parts).strip()
186
-
187
96
  if not sys.stdin.isatty():
188
97
  prompt_text = sys.stdin.read().strip()
189
98
  if prompt_text:
190
99
  return prompt_text
191
-
192
100
  raise ValueError("prompt is required either as argv text or stdin")
193
101
 
194
102
 
195
- def get_tools(
196
- runtime_environment: 'typing.Union[AgentRuntimeEnvironment, None]' = None,
197
- exec_mode: 'bool' = False,
198
- cwd: 'typing.Union[str, Path, None]' = None,
199
- toolset: 'typing.Union[typing.Iterable[str], None]' = None,
200
- ):
201
- from .tools import (
202
- ApplyPatchTool,
203
- ClockManager,
204
- ClockTool,
205
- CloseAgentTool,
206
- CodeModeManager,
207
- ExecTool,
208
- ExecCommandTool,
209
- GrepFilesTool,
210
- ListDirTool,
211
- ReadFileTool,
212
- RequestPermissionsTool,
213
- RequestUserInputTool,
214
- ResumeAgentTool,
215
- Registry,
216
- SendInputTool,
217
- ShellCommandTool,
218
- ShellTool,
219
- SpawnAgentTool,
220
- UnifiedExecManager,
221
- UpdatePlanTool,
222
- ViewImageTool,
223
- WaitAgentTool,
224
- WaitTool,
225
- WebSearchTool,
226
- WriteStdinTool,
227
- )
228
-
229
- runtime_environment = runtime_environment or create_agent_runtime_environment()
230
- registry = Registry()
231
- code_mode_manager = CodeModeManager(registry, cwd=cwd)
232
- unified_exec_manager = UnifiedExecManager(cwd=cwd)
233
- clock_manager = ClockManager()
234
- exec_tool = ExecTool(code_mode_manager)
235
- wait_tool = WaitTool(code_mode_manager)
236
- web_search_tool = WebSearchTool()
237
- update_plan_tool = UpdatePlanTool(runtime_environment.plan_store)
238
- request_user_input_tool = RequestUserInputTool(
239
- runtime_environment.request_user_input_manager
240
- )
241
- request_permissions_tool = RequestPermissionsTool(
242
- runtime_environment.request_permissions_manager
243
- )
244
- spawn_agent_tool = SpawnAgentTool(runtime_environment.subagent_manager)
245
- send_input_tool = SendInputTool(runtime_environment.subagent_manager)
246
- resume_agent_tool = ResumeAgentTool(runtime_environment.subagent_manager)
247
- wait_agent_tool = WaitAgentTool(runtime_environment.subagent_manager)
248
- close_agent_tool = CloseAgentTool(runtime_environment.subagent_manager)
249
- apply_patch_tool = ApplyPatchTool(cwd=cwd)
250
- shell_tool = ShellTool(cwd=cwd)
251
- shell_command_tool = ShellCommandTool(cwd=cwd)
252
- exec_command_tool = ExecCommandTool(unified_exec_manager)
253
- write_stdin_tool = WriteStdinTool(unified_exec_manager)
254
- clock_tool = ClockTool(clock_manager)
255
- grep_files_tool = GrepFilesTool(cwd=cwd)
256
- read_file_tool = ReadFileTool()
257
- list_dir_tool = ListDirTool()
258
- view_image_tool = ViewImageTool(cwd=cwd)
259
- tools = (
260
- shell_tool,
261
- shell_command_tool,
262
- exec_command_tool,
263
- write_stdin_tool,
264
- clock_tool,
265
- exec_tool,
266
- wait_tool,
267
- web_search_tool,
268
- update_plan_tool,
269
- request_user_input_tool,
270
- request_permissions_tool,
271
- spawn_agent_tool,
272
- send_input_tool,
273
- resume_agent_tool,
274
- wait_agent_tool,
275
- close_agent_tool,
276
- apply_patch_tool,
277
- grep_files_tool,
278
- read_file_tool,
279
- list_dir_tool,
280
- view_image_tool,
281
- )
282
- if toolset is not None:
283
- available_tools = {tool.name: tool for tool in tools}
284
- toolset = tuple(toolset)
285
- unknown_tools = set(toolset) - set(available_tools)
286
- if unknown_tools:
287
- raise ValueError(
288
- "unknown toolset entries: {0}".format(", ".join(sorted(unknown_tools)))
289
- )
290
- tools = tuple(available_tools[name] for name in toolset)
291
- elif exec_mode:
292
- tools = (
293
- exec_command_tool,
294
- write_stdin_tool,
295
- clock_tool,
296
- update_plan_tool,
297
- request_user_input_tool,
298
- apply_patch_tool,
299
- web_search_tool,
300
- view_image_tool,
301
- spawn_agent_tool,
302
- send_input_tool,
303
- resume_agent_tool,
304
- wait_agent_tool,
305
- close_agent_tool,
306
- )
307
- for tool in tools:
308
- registry.register(tool)
309
- return registry
310
-
311
-
312
- def get_subagent_tools(
313
- runtime_environment: 'typing.Union[AgentRuntimeEnvironment, None]' = None,
314
- cwd: 'typing.Union[str, Path, None]' = None,
315
- ):
316
- from .tools import (
317
- ApplyPatchTool,
318
- ExecCommandTool,
319
- Registry,
320
- UnifiedExecManager,
321
- UpdatePlanTool,
322
- ViewImageTool,
323
- WebSearchTool,
324
- WriteStdinTool,
325
- )
326
-
327
- runtime_environment = runtime_environment or create_agent_runtime_environment()
328
- registry = Registry()
329
- unified_exec_manager = UnifiedExecManager(cwd=cwd)
330
- registry.register(ExecCommandTool(unified_exec_manager))
331
- registry.register(WriteStdinTool(unified_exec_manager))
332
- registry.register(UpdatePlanTool(runtime_environment.plan_store))
333
- registry.register(ApplyPatchTool(cwd=cwd))
334
- registry.register(WebSearchTool())
335
- registry.register(ViewImageTool(cwd=cwd))
336
- return registry
337
-
338
-
339
- def build_agent(
340
- client,
341
- config_path: 'typing.Union[str, Path]' = DEFAULT_CODEX_CONFIG_PATH,
342
- profile: 'typing.Union[str, None]' = None,
343
- system_prompt: 'typing.Union[str, None]' = None,
344
- session_mode: 'CliSessionMode' = "exec",
345
- collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
346
- extra_contextual_user_messages: 'typing.Iterable[str]' = (),
347
- cwd: 'typing.Union[str, Path, None]' = None,
348
- toolset: 'typing.Union[typing.Iterable[str], None]' = None,
349
- ) -> 'Agent':
350
- config_path = str(config_path)
351
- resolved_cwd = Path(cwd or Path.cwd()).resolve()
352
- context_manager = ContextManager.from_codex_config(
353
- config_path,
354
- profile,
355
- base_instructions_override=system_prompt,
356
- collaboration_mode=collaboration_mode,
357
- include_collaboration_instructions=session_mode == "tui",
358
- extra_contextual_user_messages=extra_contextual_user_messages,
359
- cwd=resolved_cwd,
360
- )
361
- session_id = getattr(client, "_session_id", None) or uuid7_string()
362
- if hasattr(client, "_session_id"):
363
- client._session_id = session_id
364
- subagent_context_manager = ContextManager.from_codex_config(
365
- config_path,
366
- profile,
367
- base_instructions_override=system_prompt,
368
- include_collaboration_instructions=False,
369
- extra_contextual_user_messages=extra_contextual_user_messages,
370
- cwd=resolved_cwd,
371
- )
372
- runtime_environment = create_agent_runtime_environment()
373
- runtime_environment.request_user_input_manager.set_handler(None)
374
- runtime_environment.request_permissions_manager.set_handler(None)
375
- rollout_recorder = SessionRolloutRecorder.create(
376
- resolve_codex_home(config_path),
377
- session_id,
378
- context_manager.cwd,
379
- getattr(client, "_originator", CLI_ORIGINATOR),
380
- getattr(getattr(client, "_config", None), "provider_name", None),
381
- context_manager.resolve_base_instructions(),
382
- )
383
-
384
- def make_subagent_queue_builder(base_client):
385
- def build_subagent_queue(
386
- model_override: 'typing.Union[str, None]',
387
- reasoning_effort_override: 'typing.Union[str, None]',
388
- initial_history=(),
389
- session_id: 'typing.Union[str, None]' = None,
390
- ) -> 'CliSubmissionQueue':
391
- nested_client = base_client.with_overrides(
392
- model_override,
393
- reasoning_effort_override,
394
- session_id=session_id,
395
- openai_subagent="collab_spawn",
396
- )
397
- subagent_agent_runtime_environment = create_agent_runtime_environment()
398
- subagent_agent_runtime_environment.request_user_input_manager.set_handler(None)
399
- subagent_agent_runtime_environment.request_permissions_manager.set_handler(None)
400
- subagent_agent_runtime_environment.subagent_manager.set_queue_builder(
401
- make_subagent_queue_builder(nested_client)
402
- )
403
- sub_agent = Agent(
404
- nested_client,
405
- get_subagent_tools(subagent_agent_runtime_environment, cwd=resolved_cwd),
406
- subagent_context_manager,
407
- initial_history=tuple(initial_history),
408
- runtime_environment=subagent_agent_runtime_environment,
409
- )
410
- return CliSubmissionQueue(sub_agent)
411
-
412
- return build_subagent_queue
413
-
414
- runtime_environment.subagent_manager.set_queue_builder(
415
- make_subagent_queue_builder(client)
416
- )
417
- return Agent(
418
- client,
419
- get_tools(
420
- runtime_environment,
421
- exec_mode=True,
422
- cwd=resolved_cwd,
423
- toolset=toolset,
424
- ),
425
- context_manager,
426
- rollout_recorder=rollout_recorder,
427
- runtime_environment=runtime_environment,
428
- )
429
-
430
-
431
- def build_model(
432
- config_path: 'typing.Union[str, Path]' = DEFAULT_CODEX_CONFIG_PATH,
433
- profile: 'typing.Union[str, None]' = None,
434
- timeout_seconds: 'float' = 120.0,
435
- managed_responses_base_url: 'typing.Union[str, None]' = None,
436
- vllm_endpoint: 'typing.Union[str, None]' = None,
437
- use_chat_completion: 'typing.Union[bool, None]' = None,
438
- use_messages: 'bool' = False,
439
- ):
440
- load_codex_dotenv(config_path)
441
- provider_config = ResponsesProviderConfig.from_codex_config(
442
- config_path,
443
- profile,
444
- )
445
- if use_chat_completion is None:
446
- use_chat_completion = bool(provider_config.use_chat_completion)
447
- if use_chat_completion and use_messages:
448
- raise ValueError("--use-chat-completion and --use-messages cannot be combined")
449
- if vllm_endpoint and use_messages:
450
- raise ValueError("--vllm-endpoint and --use-messages cannot be combined")
451
- uses_local_responses_compat = (
452
- managed_responses_base_url is not None
453
- or vllm_endpoint is not None
454
- or bool(use_chat_completion)
455
- or use_messages
456
- )
457
- if vllm_endpoint is not None:
458
- provider_config = replace(
459
- provider_config,
460
- model=_resolve_vllm_model(
461
- vllm_endpoint,
462
- provider_config,
463
- timeout_seconds,
464
- ),
465
- )
466
- url, key_env = provider_config.base_url, provider_config.api_key_env
467
- if managed_responses_base_url is not None:
468
- url, key_env = (
469
- managed_responses_base_url,
470
- LOCAL_RESPONSES_SERVER_API_KEY_ENV,
471
- )
472
- os.environ.setdefault(LOCAL_RESPONSES_SERVER_API_KEY_ENV, "dummy")
473
- elif vllm_endpoint or use_chat_completion or use_messages:
474
- if vllm_endpoint:
475
- managed_server = launch_chat_completion_compat_server(
476
- vllm_endpoint,
477
- model_provider="vllm",
478
- )
479
- else:
480
- managed_server = launch_chat_completion_compat_server(
481
- provider_config.base_url,
482
- provider_config.api_key_env,
483
- model_provider=provider_config.provider_name,
484
- outcomming_api=(
485
- "messages" if use_messages else "chat_completions"
486
- ),
487
- )
488
- atexit.register(managed_server.stop)
489
- url, key_env = (
490
- managed_server.base_url,
491
- LOCAL_RESPONSES_SERVER_API_KEY_ENV,
492
- )
493
- os.environ.setdefault(LOCAL_RESPONSES_SERVER_API_KEY_ENV, "dummy")
494
-
495
- provider_config = replace(
496
- provider_config,
497
- base_url=url,
498
- api_key_env=key_env,
499
- responses_lite_override=(
500
- False if uses_local_responses_compat else provider_config.responses_lite_override
501
- ),
502
- )
503
- return ResponsesModelClient(
504
- provider_config,
505
- timeout_seconds,
506
- originator=CLI_ORIGINATOR,
507
- )
508
-
509
-
510
- def build_cli_queue(agent: 'Agent') -> 'CliSubmissionQueue':
511
- return CliSubmissionQueue(agent)
512
-
513
-
514
- async def run_interactive_session(
515
- queue: 'CliSubmissionQueue',
516
- json_mode: 'bool',
517
- config_path: 'typing.Union[str, None]' = None,
518
- ) -> 'int':
519
- return await _run_interactive_session(
520
- queue,
521
- json_mode,
522
- config_path,
523
- view_factory=CliSessionView,
103
+ async def run_cli(args):
104
+ runtime = None
105
+ debug_dir = get_debug_dir()
106
+ phase_handle = (
107
+ None
108
+ if debug_dir is None
109
+ else (debug_dir / "phase.log").open("a", encoding="utf-8")
524
110
  )
525
111
 
112
+ def phase(message):
113
+ if phase_handle is not None:
114
+ phase_handle.write(message + "\n")
115
+ phase_handle.flush()
526
116
 
527
- async def run_cli(args: 'argparse.Namespace') -> 'int':
528
- queued_agent = None
529
- worker = None
530
- debug_dir = get_debug_dir()
531
- phase_handle = None if debug_dir is None else (debug_dir / "phase.log").open("a", encoding="utf-8")
532
117
  try:
533
118
  if args.put is not None and args.call:
534
119
  raise ValueError("--put and --call cannot be combined")
@@ -537,7 +122,8 @@ async def run_cli(args: 'argparse.Namespace') -> 'int':
537
122
  configure_loguru()
538
123
  config_path = args.config
539
124
  if args.put is not None:
540
- def emit_put_log(message: 'str') -> 'None':
125
+
126
+ def emit_put_log(message):
541
127
  print(message, flush=True)
542
128
 
543
129
  call_spec = upload_codex_home(args.put, event_handler=emit_put_log)
@@ -549,17 +135,11 @@ async def run_cli(args: 'argparse.Namespace') -> 'int':
549
135
  print(f"pycodex --call {shlex.quote(call_spec)}", flush=True)
550
136
  return 0
551
137
  if args.call:
552
- if phase_handle is not None:
553
- phase_handle.write("bootstrap_called_home:start\n")
554
- phase_handle.flush()
138
+ phase("bootstrap_called_home:start")
555
139
  config_path = bootstrap_called_home(args.call)
556
- if phase_handle is not None:
557
- phase_handle.write("bootstrap_called_home:done\n")
558
- phase_handle.flush()
140
+ phase("bootstrap_called_home:done")
559
141
  os.environ["CODEX_HOME"] = str(config_path.parent)
560
- if phase_handle is not None:
561
- phase_handle.write("build_model:start\n")
562
- phase_handle.flush()
142
+ phase("build_model:start")
563
143
  model = build_model(
564
144
  config_path=str(config_path),
565
145
  profile=args.profile,
@@ -568,79 +148,227 @@ async def run_cli(args: 'argparse.Namespace') -> 'int':
568
148
  use_chat_completion=args.use_chat_completion or None,
569
149
  use_messages=args.use_messages,
570
150
  )
571
- if phase_handle is not None:
572
- phase_handle.write("build_model:done\n")
573
- phase_handle.write("build_agent:start\n")
574
- phase_handle.flush()
151
+ phase("build_model:done")
152
+ phase("build_agent:start")
575
153
  agent = build_agent(
576
154
  model,
577
155
  config_path=str(config_path),
578
156
  profile=args.profile,
579
157
  system_prompt=args.system_prompt,
580
- session_mode="tui",
581
158
  )
582
- if phase_handle is not None:
583
- phase_handle.write("build_agent:done\n")
584
- phase_handle.write("build_cli_queue:start\n")
585
- phase_handle.flush()
586
- queued_agent = build_cli_queue(agent)
587
- if phase_handle is not None:
588
- phase_handle.write("build_cli_queue:done\n")
589
- phase_handle.flush()
159
+ phase("build_agent:done")
160
+ runtime = build_runtime(agent)
590
161
  if should_run_interactive(args.prompt, sys.stdin.isatty()):
591
- return await run_interactive_session(
592
- queued_agent,
593
- args.json,
594
- str(config_path),
595
- )
596
- else:
597
- prompt_text = resolve_prompt_text(args.prompt)
598
- worker = asyncio.create_task(queued_agent.run_forever())
599
- if phase_handle is not None:
600
- phase_handle.write("submit_user_turn:start\n")
601
- phase_handle.flush()
602
- result = await queued_agent.submit_user_turn(prompt_text)
603
- if phase_handle is not None:
604
- phase_handle.write("submit_user_turn:done\n")
605
- phase_handle.flush()
606
- print(format_turn_output(result, args.json))
607
- return 0
162
+ return await run_interactive_session(runtime, args.json, str(config_path))
163
+ prompt_text = resolve_prompt_text(args.prompt)
164
+ await runtime.start(str(config_path))
165
+ phase("submit_input:start")
166
+ receipt = await runtime.submit_input(prompt_text, "cli")
167
+ result = await receipt.future
168
+ phase("submit_input:done")
169
+ render_result(receipt.kind, result, args.json, print)
170
+ return 0
608
171
  except Exception as exc:
609
- if phase_handle is not None:
610
- phase_handle.write("fatal_exception\n")
611
- phase_handle.flush()
172
+ phase("fatal_exception")
612
173
  if debug_dir is not None:
613
174
  (debug_dir / "fatal_error.txt").write_text(
614
175
  traceback.format_exc(), encoding="utf-8"
615
176
  )
616
- print(f"Error: {exc}", file=sys.stderr)
177
+ print(format_error(exc, indent=False), file=sys.stderr)
617
178
  return 1
618
179
  finally:
619
180
  if phase_handle is not None:
620
181
  phase_handle.close()
621
- if queued_agent is not None and worker is not None:
622
- await queued_agent.shutdown()
623
- await worker
182
+ if runtime is not None:
183
+ await runtime.close()
184
+
185
+
186
+ @contextmanager
187
+ def _cli_sigint_handler(runtime):
188
+ if threading.current_thread() is not threading.main_thread():
189
+ yield lambda: None
190
+ return
191
+
192
+ def handle_sigint(signum, frame):
193
+ if not runtime.accepts_input:
194
+ # A second interrupt must not re-enter asyncio's shutdown waits.
195
+ os._exit(130)
196
+ signal.default_int_handler(signum, frame)
197
+
198
+ def install_handler():
199
+ signal.signal(signal.SIGINT, handle_sigint)
200
+
201
+ previous_handler = signal.getsignal(signal.SIGINT)
202
+ install_handler()
203
+ try:
204
+ yield install_handler
205
+ finally:
206
+ signal.signal(signal.SIGINT, previous_handler)
207
+
208
+
209
+ async def run_interactive_session(runtime, json_mode, config_path=None, view=None):
210
+ if view is None:
211
+ view = CliSessionView()
212
+ await runtime.start(config_path)
213
+ frontend_id = runtime.attach(view.handle_event)
214
+
215
+ def show_result(future):
216
+ if not future.cancelled() and future.exception() is None:
217
+ render_result("turn", future.result(), True, view.write_line)
624
218
 
625
- def ipython_agent(config_path: 'str' = DEFAULT_CODEX_CONFIG_PATH):
219
+ view.display.start(runtime.commands())
220
+ with _cli_sigint_handler(runtime) as install_sigint_handler:
221
+ try:
222
+ while not view.display.closed:
223
+ try:
224
+ raw_line = await view.poll_prompt()
225
+ except EOFError:
226
+ break
227
+ if raw_line is None:
228
+ await asyncio.sleep(0.05)
229
+ continue
230
+ install_sigint_handler()
231
+ try:
232
+ receipt = await runtime.submit_input(raw_line, sender="cli")
233
+ except Exception as exc:
234
+ view.display.show_error(str(exc))
235
+ continue
236
+ if json_mode and receipt.kind == "turn":
237
+ receipt.future.add_done_callback(show_result)
238
+ finally:
239
+ # Older prompt_toolkit versions reset SIGINT when the prompt exits.
240
+ install_sigint_handler()
241
+ try:
242
+ await runtime.close()
243
+ finally:
244
+ runtime.detach(frontend_id)
245
+ view.close()
246
+ return 0
247
+
248
+
249
+ class Prompter:
250
+ def __init__(self, prompt: str = DEFAULT_MAIN_PROMPT, lock=None):
251
+ self.lock = lock or threading.Lock()
252
+ self._prompt_session = PromptSession(**prompt_session_kwargs())
253
+ self.prompt = prompt
254
+ self._status = None
255
+ self._status_frame_index = 0
256
+ self._prompt_task = None
257
+
258
+ def set_prompt(self, prompt):
259
+ self.prompt = prompt
260
+
261
+ def set_status(self, text):
262
+ self._status = text
263
+
264
+ async def poll_input(self) -> "typing.Union[str, None]":
265
+ if self._prompt_task is None:
266
+ self._prompt_task = asyncio.create_task(self._block_prompt())
267
+ done, _pending = await asyncio.wait(
268
+ {self._prompt_task},
269
+ timeout=0.05,
270
+ return_when=asyncio.FIRST_COMPLETED,
271
+ )
272
+ if not done:
273
+ return None
274
+ prompt_task, self._prompt_task = self._prompt_task, None
275
+ try:
276
+ return prompt_task.result()
277
+ except asyncio.CancelledError:
278
+ return None
279
+
280
+ async def _block_prompt(self):
281
+ with patch_stdout(raw=True):
282
+ return await self._prompt_session.prompt_async(
283
+ lambda: self.prompt,
284
+ refresh_interval=0.12,
285
+ bottom_toolbar=self._get_status,
286
+ set_exception_handler=False,
287
+ )
288
+
289
+ def _get_status(self):
290
+ self._status_frame_index += 1
291
+ return EventDisplay.status_frame(self._status, self._status_frame_index)
292
+
293
+ def close(self) -> "None":
294
+ if self._prompt_task is not None and not self._prompt_task.done():
295
+ self._prompt_task.cancel()
296
+ self._prompt_task = None
297
+
298
+
299
+ def prompt_session_kwargs() -> "typing.Dict[str, object]":
300
+ key_bindings = KeyBindings()
301
+
302
+ @key_bindings.add("c-c", filter=has_focus(DEFAULT_BUFFER))
303
+ @key_bindings.add("<sigint>")
304
+ def exit_prompt(event):
305
+ event.app.exit(exception=EOFError, style="class:aborting")
306
+
307
+ kwargs = {
308
+ "erase_when_done": True,
309
+ "enable_system_prompt": True,
310
+ "key_bindings": key_bindings,
311
+ }
312
+ try:
313
+ parameters = inspect.signature(PromptSession.__init__).parameters
314
+ except (TypeError, ValueError):
315
+ return kwargs
316
+ if "show_frame" in parameters:
317
+ kwargs["show_frame"] = True
318
+ return kwargs
319
+
320
+
321
+ class CliSessionView:
322
+ """Execute terminal I/O; events own presentation decisions and state."""
323
+
324
+ def __init__(self, context_window_tokens=None):
325
+ self._line_output = print
326
+ self._terminal_lock = threading.RLock()
327
+ self.prompter = Prompter(lock=self._terminal_lock)
328
+ color_enabled = sys.stdout.isatty() and os.environ.get(
329
+ "PYCODEX_NO_COLOR",
330
+ "",
331
+ ).strip().lower() not in {"1", "true", "yes", "on"}
332
+ self.display = EventDisplay(
333
+ self.write_line,
334
+ self.prompter.set_status,
335
+ self.prompter.set_prompt,
336
+ color_enabled,
337
+ context_window_tokens,
338
+ )
339
+
340
+ def handle_event(self, event):
341
+ with self._terminal_lock:
342
+ event.render(self.display)
343
+
344
+ def write_line(self, text):
345
+ with self._terminal_lock:
346
+ self._line_output(text)
347
+
348
+ async def poll_prompt(self, prompt=None):
349
+ if prompt:
350
+ self.prompter.set_prompt(prompt)
351
+ return await self.prompter.poll_input()
352
+
353
+ def close(self):
354
+ self.prompter.close()
355
+
356
+
357
+ def ipython_agent(config_path=DEFAULT_CODEX_CONFIG_PATH):
626
358
  from loguru import logger
359
+
360
+ from .tools.ipython_tool import attach_ipython_tool
361
+
627
362
  logger.remove()
628
363
  logger.add(sys.stderr, level="INFO")
629
-
630
364
  model = build_model(config_path)
631
365
  agent = build_agent(client=model, config_path=config_path)
632
-
633
- from pycodex.tools.ipython_tool import attach_ipython_tool
634
-
635
366
  attach_ipython_tool(agent)
636
-
637
367
  return agent
638
368
 
639
- def main(argv: 'typing.Union[Sequence[str], None]' = None) -> 'int':
640
- raw_args = list(argv) if argv is not None else None
641
- if raw_args is None:
642
- raw_args = sys.argv[1:]
643
369
 
370
+ def main(argv=None):
371
+ raw_args = list(argv) if argv is not None else sys.argv[1:]
644
372
  if raw_args and raw_args[0] == "doctor":
645
373
  from .doctor import build_doctor_parser, run_doctor_cli
646
374
 
@@ -653,10 +381,8 @@ def main(argv: 'typing.Union[Sequence[str], None]' = None) -> 'int':
653
381
  except KeyboardInterrupt:
654
382
  return 130
655
383
  return 0
656
-
657
384
  parser = build_parser()
658
385
  args = parser.parse_args(raw_args)
659
-
660
386
  try:
661
387
  return asyncio.run(run_cli(args))
662
388
  except ValueError as exc: