python-codex 0.1.13__py3-none-any.whl → 0.2.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.
- pycodex/agent.py +71 -11
- pycodex/cli.py +16 -356
- pycodex/context.py +12 -0
- pycodex/feishu_card.py +76 -30
- pycodex/feishu_link.py +131 -11
- pycodex/interactive_session.py +397 -0
- pycodex/model.py +11 -22
- pycodex/protocol.py +0 -5
- pycodex/runtime.py +23 -0
- pycodex/runtime_services.py +2 -2
- pycodex/tools/agent_tool_schemas.py +1 -1
- pycodex/tools/apply_patch_tool.py +1 -1
- pycodex/tools/base_tool.py +1 -27
- pycodex/tools/close_agent_tool.py +11 -4
- pycodex/tools/code_mode_manager.py +1 -1
- pycodex/tools/exec_command_tool.py +40 -16
- pycodex/tools/exec_tool.py +18 -2
- pycodex/tools/grep_files_tool.py +19 -6
- pycodex/tools/ipython_tool.py +3 -2
- pycodex/tools/list_dir_tool.py +19 -6
- pycodex/tools/read_file_tool.py +39 -9
- pycodex/tools/request_permissions_tool.py +12 -1
- pycodex/tools/request_user_input_tool.py +28 -1
- pycodex/tools/send_input_tool.py +4 -2
- pycodex/tools/shell_command_tool.py +23 -6
- pycodex/tools/shell_tool.py +13 -4
- pycodex/tools/spawn_agent_tool.py +31 -8
- pycodex/tools/unified_exec_manager.py +49 -93
- pycodex/tools/update_plan_tool.py +14 -6
- pycodex/tools/view_image_tool.py +17 -16
- pycodex/tools/wait_agent_tool.py +15 -3
- pycodex/tools/wait_tool.py +18 -4
- pycodex/tools/web_search_tool.py +2 -1
- pycodex/tools/write_stdin_tool.py +42 -10
- pycodex/utils/compactor.py +7 -1
- pycodex/utils/session_persist.py +42 -1
- pycodex/utils/truncation.py +206 -0
- pycodex/utils/visualize.py +34 -15
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/METADATA +4 -1
- python_codex-0.2.0.dist-info/RECORD +88 -0
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/entry_points.txt +1 -0
- workspace_server/__init__.py +23 -0
- workspace_server/__main__.py +5 -0
- workspace_server/app.py +1347 -0
- workspace_server/workspace.html +866 -0
- pycodex/prompts/exec_tools.json +0 -411
- pycodex/prompts/subagent_tools.json +0 -163
- python_codex-0.1.13.dist-info/RECORD +0 -84
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/WHEEL +0 -0
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/licenses/LICENSE +0 -0
pycodex/agent.py
CHANGED
|
@@ -17,7 +17,8 @@ from .protocol import (
|
|
|
17
17
|
TurnResult,
|
|
18
18
|
UserMessage,
|
|
19
19
|
)
|
|
20
|
-
from .tools import ToolContext, ToolRegistry
|
|
20
|
+
from .tools import ExecCommandTool, ToolContext, ToolRegistry, UnifiedExecManager
|
|
21
|
+
from .utils.truncation import truncate_tool_results_for_history
|
|
21
22
|
from .utils import uuid7_string
|
|
22
23
|
import typing
|
|
23
24
|
|
|
@@ -46,6 +47,7 @@ _CONTEXT_LENGTH_ERROR_MARKERS = (
|
|
|
46
47
|
"exceeds the context window",
|
|
47
48
|
"exceeded the context window",
|
|
48
49
|
)
|
|
50
|
+
TERMINAL_TURN_EVENTS = {"turn_completed", "turn_failed", "turn_interrupted"}
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
class TurnInterrupted(RuntimeError):
|
|
@@ -85,6 +87,15 @@ class Agent:
|
|
|
85
87
|
self._last_total_usage_tokens: 'typing.Union[int, None]' = None
|
|
86
88
|
self.runtime_environment = runtime_environment
|
|
87
89
|
self.interrupt_asap = False
|
|
90
|
+
self._turn_running = False
|
|
91
|
+
exec_command_tool = self._tool_registry.get_tool("exec_command")
|
|
92
|
+
self._exec_manager = (
|
|
93
|
+
exec_command_tool._manager
|
|
94
|
+
if isinstance(exec_command_tool, ExecCommandTool)
|
|
95
|
+
else None
|
|
96
|
+
)
|
|
97
|
+
if self._exec_manager is not None:
|
|
98
|
+
self._exec_manager.set_notify_hook(self.maybe_invoke)
|
|
88
99
|
|
|
89
100
|
@property
|
|
90
101
|
def history(self) -> 'typing.Tuple[ConversationItem, ...]':
|
|
@@ -129,6 +140,7 @@ class Agent:
|
|
|
129
140
|
async def run_turn(
|
|
130
141
|
self, texts: 'typing.List[str]', turn_id: 'typing.Union[str, None]' = None
|
|
131
142
|
) -> 'TurnResult':
|
|
143
|
+
self._turn_running = True
|
|
132
144
|
turn_id = turn_id or uuid7_string()
|
|
133
145
|
self.interrupt_asap = False
|
|
134
146
|
new_user_messages = [UserMessage(text=text) for text in texts]
|
|
@@ -168,16 +180,10 @@ class Agent:
|
|
|
168
180
|
item_count=len(response.items),
|
|
169
181
|
)
|
|
170
182
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
persisted_response_items.append(item)
|
|
176
|
-
if isinstance(item, AssistantMessage):
|
|
177
|
-
last_assistant_message = item.text
|
|
178
|
-
elif isinstance(item, ToolCall):
|
|
179
|
-
tool_calls.append(item)
|
|
180
|
-
self._persist_history_items(persisted_response_items)
|
|
183
|
+
recorded_items = self._record_model_response_items(response.items)
|
|
184
|
+
tool_calls = recorded_items[1]
|
|
185
|
+
if recorded_items[2] is not None:
|
|
186
|
+
last_assistant_message = recorded_items[2]
|
|
181
187
|
|
|
182
188
|
if not tool_calls:
|
|
183
189
|
self._raise_if_interrupt_requested(
|
|
@@ -191,6 +197,7 @@ class Agent:
|
|
|
191
197
|
iteration=iteration,
|
|
192
198
|
output_text=last_assistant_message,
|
|
193
199
|
)
|
|
200
|
+
self._turn_running = False
|
|
194
201
|
return TurnResult(
|
|
195
202
|
turn_id=turn_id,
|
|
196
203
|
output_text=last_assistant_message,
|
|
@@ -200,6 +207,7 @@ class Agent:
|
|
|
200
207
|
)
|
|
201
208
|
|
|
202
209
|
tool_results = await self._execute_tool_batch(turn_id, tool_calls)
|
|
210
|
+
tool_results = truncate_tool_results_for_history(tool_results)
|
|
203
211
|
self._history.extend(tool_results)
|
|
204
212
|
self._persist_history_items(tool_results)
|
|
205
213
|
follow_up_messages = self._build_follow_up_messages(tool_results)
|
|
@@ -211,6 +219,10 @@ class Agent:
|
|
|
211
219
|
output_text=last_assistant_message,
|
|
212
220
|
)
|
|
213
221
|
except TurnInterrupted:
|
|
222
|
+
self._turn_running = False
|
|
223
|
+
raise
|
|
224
|
+
except asyncio.CancelledError:
|
|
225
|
+
self._turn_running = False
|
|
214
226
|
raise
|
|
215
227
|
except Exception as exc:
|
|
216
228
|
context_usage = _usage_from_context_length_error(str(exc))
|
|
@@ -224,8 +236,29 @@ class Agent:
|
|
|
224
236
|
error=str(exc),
|
|
225
237
|
error_type=type(exc).__name__,
|
|
226
238
|
)
|
|
239
|
+
self._turn_running = False
|
|
227
240
|
raise
|
|
228
241
|
|
|
242
|
+
async def maybe_invoke(self, event: 'typing.Dict[str, object]') -> 'bool':
|
|
243
|
+
if self._turn_running or event.get("type") != "exec_command_completed":
|
|
244
|
+
return False
|
|
245
|
+
payload = {
|
|
246
|
+
"session_id": event.get("session_id"),
|
|
247
|
+
"exit_code": event.get("exit_code"),
|
|
248
|
+
"command": event.get("command"),
|
|
249
|
+
}
|
|
250
|
+
text = (
|
|
251
|
+
"<exec_command_completed>\n"
|
|
252
|
+
f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n"
|
|
253
|
+
"</exec_command_completed>"
|
|
254
|
+
)
|
|
255
|
+
self._turn_running = True
|
|
256
|
+
task = asyncio.create_task(self.run_turn([text]))
|
|
257
|
+
task.add_done_callback(
|
|
258
|
+
lambda task: None if task.cancelled() else task.exception()
|
|
259
|
+
)
|
|
260
|
+
return True
|
|
261
|
+
|
|
229
262
|
async def _execute_tool_batch(
|
|
230
263
|
self,
|
|
231
264
|
turn_id: 'str',
|
|
@@ -294,10 +327,18 @@ class Agent:
|
|
|
294
327
|
return result
|
|
295
328
|
|
|
296
329
|
def _emit(self, kind: 'str', turn_id: 'str', **payload: 'object') -> 'None':
|
|
330
|
+
if kind in TERMINAL_TURN_EVENTS:
|
|
331
|
+
payload["background_exec_count"] = self._background_exec_count()
|
|
297
332
|
self._event_handler(
|
|
298
333
|
AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
|
|
299
334
|
)
|
|
300
335
|
|
|
336
|
+
def _background_exec_count(self) -> 'int':
|
|
337
|
+
manager: 'typing.Union[UnifiedExecManager, None]' = self._exec_manager
|
|
338
|
+
if manager is None:
|
|
339
|
+
return 0
|
|
340
|
+
return manager.running_session_count()
|
|
341
|
+
|
|
301
342
|
def _persist_history_items(
|
|
302
343
|
self,
|
|
303
344
|
items: 'typing.Iterable[ConversationItem]',
|
|
@@ -310,6 +351,25 @@ class Agent:
|
|
|
310
351
|
except Exception: # pragma: no cover - persistence should not break turns
|
|
311
352
|
return
|
|
312
353
|
|
|
354
|
+
def _record_model_response_items(
|
|
355
|
+
self,
|
|
356
|
+
items: 'typing.Iterable[object]',
|
|
357
|
+
) -> 'typing.Tuple[typing.Tuple[ConversationItem, ...], typing.List[ToolCall], typing.Union[str, None]]':
|
|
358
|
+
persisted_response_items: 'typing.List[ConversationItem]' = []
|
|
359
|
+
tool_calls: 'typing.List[ToolCall]' = []
|
|
360
|
+
last_assistant_message = None
|
|
361
|
+
for item in items:
|
|
362
|
+
if not isinstance(item, (AssistantMessage, ToolCall, ReasoningItem)):
|
|
363
|
+
continue
|
|
364
|
+
self._history.append(item)
|
|
365
|
+
persisted_response_items.append(item)
|
|
366
|
+
if isinstance(item, AssistantMessage):
|
|
367
|
+
last_assistant_message = item.text
|
|
368
|
+
elif isinstance(item, ToolCall):
|
|
369
|
+
tool_calls.append(item)
|
|
370
|
+
self._persist_history_items(persisted_response_items)
|
|
371
|
+
return tuple(persisted_response_items), tool_calls, last_assistant_message
|
|
372
|
+
|
|
313
373
|
def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
|
|
314
374
|
if event.kind == "token_count":
|
|
315
375
|
self._remember_token_usage(event.payload.get("usage"))
|
pycodex/cli.py
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
import atexit
|
|
3
3
|
import argparse
|
|
4
4
|
import asyncio
|
|
5
|
-
import json
|
|
6
5
|
import os
|
|
7
6
|
import shlex
|
|
8
7
|
import sys
|
|
9
8
|
import tempfile
|
|
10
9
|
import traceback
|
|
11
|
-
from dataclasses import
|
|
10
|
+
from dataclasses import replace
|
|
12
11
|
from pathlib import Path
|
|
13
12
|
from typing import Sequence
|
|
14
13
|
|
|
@@ -18,32 +17,22 @@ from .compat import Literal
|
|
|
18
17
|
from .context import ContextManager
|
|
19
18
|
from .model import DEFAULT_CODEX_CONFIG_PATH, ResponsesModelClient, ResponsesProviderConfig
|
|
20
19
|
from .portable import bootstrap_called_home, upload_codex_home
|
|
21
|
-
from .protocol import AgentEvent
|
|
22
20
|
from .runtime import CliSubmissionQueue
|
|
23
21
|
from .runtime_services import AgentRuntimeEnvironment, create_agent_runtime_environment
|
|
24
22
|
from .utils import CliSessionView, get_debug_dir, load_codex_dotenv, uuid7_string
|
|
25
|
-
from .
|
|
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
|
+
)
|
|
26
30
|
from .utils.session_persist import (
|
|
27
31
|
SessionRolloutRecorder,
|
|
28
|
-
conversation_history_to_turns,
|
|
29
|
-
list_resumable_sessions,
|
|
30
|
-
load_resumed_session,
|
|
31
32
|
resolve_codex_home,
|
|
32
33
|
)
|
|
33
34
|
import typing
|
|
34
35
|
|
|
35
|
-
EXIT_COMMANDS = {"/exit", "/quit"}
|
|
36
|
-
HISTORY_COMMAND = "/history"
|
|
37
|
-
TITLE_COMMAND = "/title"
|
|
38
|
-
MODEL_COMMAND = "/model"
|
|
39
|
-
QUEUE_COMMAND = "/queue"
|
|
40
|
-
RESUME_COMMAND = "/resume"
|
|
41
|
-
COMPACT_COMMAND = "/compact"
|
|
42
|
-
LINK_COMMAND = "/link"
|
|
43
|
-
UNLINK_COMMAND = "/unlink"
|
|
44
|
-
EXTRA_COMMANDS_LINE = (
|
|
45
|
-
"Extra commands: /history, /title, /model, /resume, /compact, /link, /unlink"
|
|
46
|
-
)
|
|
47
36
|
CliSessionMode = Literal["exec", "tui"]
|
|
48
37
|
LOCAL_RESPONSES_SERVER_API_KEY_ENV = "PYCODEX_LOCAL_RESPONSES_SERVER_KEY"
|
|
49
38
|
CLI_ORIGINATOR = "codex-tui"
|
|
@@ -300,6 +289,7 @@ def build_agent(
|
|
|
300
289
|
system_prompt: 'typing.Union[str, None]' = None,
|
|
301
290
|
session_mode: 'CliSessionMode' = "exec",
|
|
302
291
|
collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
|
|
292
|
+
extra_contextual_user_messages: 'typing.Iterable[str]' = (),
|
|
303
293
|
) -> 'Agent':
|
|
304
294
|
config_path = str(config_path)
|
|
305
295
|
context_manager = ContextManager.from_codex_config(
|
|
@@ -308,6 +298,7 @@ def build_agent(
|
|
|
308
298
|
base_instructions_override=system_prompt,
|
|
309
299
|
collaboration_mode=collaboration_mode,
|
|
310
300
|
include_collaboration_instructions=session_mode == "tui",
|
|
301
|
+
extra_contextual_user_messages=extra_contextual_user_messages,
|
|
311
302
|
)
|
|
312
303
|
session_id = getattr(client, "_session_id", None) or uuid7_string()
|
|
313
304
|
if hasattr(client, "_session_id"):
|
|
@@ -317,6 +308,7 @@ def build_agent(
|
|
|
317
308
|
profile,
|
|
318
309
|
base_instructions_override=system_prompt,
|
|
319
310
|
include_collaboration_instructions=False,
|
|
311
|
+
extra_contextual_user_messages=extra_contextual_user_messages,
|
|
320
312
|
)
|
|
321
313
|
runtime_environment = create_agent_runtime_environment()
|
|
322
314
|
runtime_environment.request_user_input_manager.set_handler(None)
|
|
@@ -372,12 +364,6 @@ def build_agent(
|
|
|
372
364
|
)
|
|
373
365
|
|
|
374
366
|
|
|
375
|
-
def format_turn_output(result, json_mode: 'bool') -> 'str':
|
|
376
|
-
if json_mode:
|
|
377
|
-
return json.dumps(asdict(result), ensure_ascii=False, indent=2)
|
|
378
|
-
return result.output_text or ""
|
|
379
|
-
|
|
380
|
-
|
|
381
367
|
def build_model(
|
|
382
368
|
config_path: 'typing.Union[str, Path]' = DEFAULT_CODEX_CONFIG_PATH,
|
|
383
369
|
profile: 'typing.Union[str, None]' = None,
|
|
@@ -443,343 +429,17 @@ def build_cli_queue(agent: 'Agent') -> 'CliSubmissionQueue':
|
|
|
443
429
|
return CliSubmissionQueue(agent)
|
|
444
430
|
|
|
445
431
|
|
|
446
|
-
async def prompt_request_user_input(
|
|
447
|
-
view: 'CliSessionView',
|
|
448
|
-
payload: 'typing.Dict[str, object]',
|
|
449
|
-
) -> 'typing.Union[typing.Dict[str, object], None]':
|
|
450
|
-
view.finish_stream()
|
|
451
|
-
view.write_line("[request_user_input] waiting for user response")
|
|
452
|
-
answers: 'typing.Dict[str, typing.Dict[str, typing.List[str]]]' = {}
|
|
453
|
-
for question in payload.get("questions", []):
|
|
454
|
-
if not isinstance(question, dict):
|
|
455
|
-
continue
|
|
456
|
-
header = str(question.get("header", "")).strip()
|
|
457
|
-
question_text = str(question.get("question", "")).strip()
|
|
458
|
-
question_id = str(question.get("id", "")).strip()
|
|
459
|
-
if header:
|
|
460
|
-
view.write_line(f"[{header}] {question_text}")
|
|
461
|
-
else:
|
|
462
|
-
view.write_line(question_text)
|
|
463
|
-
|
|
464
|
-
options = question.get("options") or []
|
|
465
|
-
if isinstance(options, list):
|
|
466
|
-
for index, option in enumerate(options, start=1):
|
|
467
|
-
if not isinstance(option, dict):
|
|
468
|
-
continue
|
|
469
|
-
label = str(option.get("label", "")).strip()
|
|
470
|
-
description = str(option.get("description", "")).strip()
|
|
471
|
-
view.write_line(f" {index}. {label} - {description}")
|
|
472
|
-
view.write_line(" 0. Other")
|
|
473
|
-
|
|
474
|
-
try:
|
|
475
|
-
raw_answer = await view.get_prompt("answer> ")
|
|
476
|
-
except EOFError:
|
|
477
|
-
return None
|
|
478
|
-
answer_text = raw_answer.strip()
|
|
479
|
-
if not answer_text:
|
|
480
|
-
return None
|
|
481
|
-
|
|
482
|
-
selected_answer = answer_text
|
|
483
|
-
if answer_text.isdigit() and isinstance(options, list):
|
|
484
|
-
choice = int(answer_text)
|
|
485
|
-
if 1 <= choice <= len(options):
|
|
486
|
-
option = options[choice - 1]
|
|
487
|
-
if isinstance(option, dict):
|
|
488
|
-
selected_answer = (
|
|
489
|
-
str(option.get("label", "")).strip() or answer_text
|
|
490
|
-
)
|
|
491
|
-
elif choice == 0:
|
|
492
|
-
try:
|
|
493
|
-
raw_answer = await view.get_prompt("other> ")
|
|
494
|
-
except EOFError:
|
|
495
|
-
return None
|
|
496
|
-
selected_answer = raw_answer.strip()
|
|
497
|
-
if not selected_answer:
|
|
498
|
-
return None
|
|
499
|
-
|
|
500
|
-
answers[question_id] = {"answers": [selected_answer]}
|
|
501
|
-
|
|
502
|
-
return {"answers": answers}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
async def prompt_request_permissions(
|
|
506
|
-
view: 'CliSessionView',
|
|
507
|
-
payload: 'typing.Dict[str, object]',
|
|
508
|
-
) -> 'typing.Union[typing.Dict[str, object], None]':
|
|
509
|
-
view.finish_stream()
|
|
510
|
-
view.write_line("[request_permissions] user approval required")
|
|
511
|
-
reason = payload.get("reason")
|
|
512
|
-
if reason:
|
|
513
|
-
view.write_line(f"Reason: {reason}")
|
|
514
|
-
view.write_line("Requested permissions:")
|
|
515
|
-
view.write_line(
|
|
516
|
-
json.dumps(payload.get("permissions", {}), ensure_ascii=False, indent=2)
|
|
517
|
-
)
|
|
518
|
-
view.write_line("Choose: [n] deny / [t] grant for turn / [s] grant for session")
|
|
519
|
-
try:
|
|
520
|
-
raw_answer = await view.get_prompt("permissions> ")
|
|
521
|
-
except EOFError:
|
|
522
|
-
return None
|
|
523
|
-
|
|
524
|
-
answer = raw_answer.strip().lower()
|
|
525
|
-
if answer in {"t", "turn", "y", "yes"}:
|
|
526
|
-
return {
|
|
527
|
-
"permissions": payload.get("permissions", {}),
|
|
528
|
-
"scope": "turn",
|
|
529
|
-
}
|
|
530
|
-
if answer in {"s", "session"}:
|
|
531
|
-
return {
|
|
532
|
-
"permissions": payload.get("permissions", {}),
|
|
533
|
-
"scope": "session",
|
|
534
|
-
}
|
|
535
|
-
return {
|
|
536
|
-
"permissions": {},
|
|
537
|
-
"scope": "turn",
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
|
|
541
432
|
async def run_interactive_session(
|
|
542
433
|
queue: 'CliSubmissionQueue',
|
|
543
434
|
json_mode: 'bool',
|
|
544
435
|
config_path: 'typing.Union[str, None]' = None,
|
|
545
436
|
) -> 'int':
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
codex_home = resolve_codex_home(config_path)
|
|
552
|
-
queue.set_event_handler(view.handle_event)
|
|
553
|
-
pending_turn_tasks: 'typing.Set[asyncio.Task[None]]' = set()
|
|
554
|
-
runtime_environment = queue._agent.runtime_environment
|
|
555
|
-
if runtime_environment is None:
|
|
556
|
-
runtime_environment = create_agent_runtime_environment()
|
|
557
|
-
queue._agent.runtime_environment = runtime_environment
|
|
558
|
-
runtime_environment.request_user_input_manager.set_handler(
|
|
559
|
-
lambda payload: prompt_request_user_input(view, payload)
|
|
560
|
-
)
|
|
561
|
-
runtime_environment.request_permissions_manager.set_handler(
|
|
562
|
-
lambda payload: prompt_request_permissions(view, payload)
|
|
437
|
+
return await _run_interactive_session(
|
|
438
|
+
queue,
|
|
439
|
+
json_mode,
|
|
440
|
+
config_path,
|
|
441
|
+
view_factory=CliSessionView,
|
|
563
442
|
)
|
|
564
|
-
view.write_line("pycodex interactive mode. Type /exit to quit.")
|
|
565
|
-
view.write_line(EXTRA_COMMANDS_LINE)
|
|
566
|
-
feishu_link = None
|
|
567
|
-
try:
|
|
568
|
-
|
|
569
|
-
def has_pending_turn_tasks() -> 'bool':
|
|
570
|
-
pending_turn_tasks.difference_update(
|
|
571
|
-
task for task in tuple(pending_turn_tasks) if task.done()
|
|
572
|
-
)
|
|
573
|
-
return bool(pending_turn_tasks)
|
|
574
|
-
|
|
575
|
-
async def run_manual_compact() -> 'None':
|
|
576
|
-
current_agent = queue._agent
|
|
577
|
-
if not current_agent.history:
|
|
578
|
-
view.write_line("Nothing to compact.")
|
|
579
|
-
return
|
|
580
|
-
|
|
581
|
-
compact_turn_id = uuid7_string()
|
|
582
|
-
|
|
583
|
-
def handle_compact_stream_event(event) -> 'None':
|
|
584
|
-
if event.kind not in {"token_count", "stream_error"}:
|
|
585
|
-
return
|
|
586
|
-
view.handle_event(
|
|
587
|
-
AgentEvent(
|
|
588
|
-
kind=event.kind,
|
|
589
|
-
turn_id=compact_turn_id,
|
|
590
|
-
payload=dict(event.payload),
|
|
591
|
-
)
|
|
592
|
-
)
|
|
593
|
-
|
|
594
|
-
view.write_line("Compacting conversation history...")
|
|
595
|
-
compact_result = await compact_agent(
|
|
596
|
-
current_agent,
|
|
597
|
-
handle_compact_stream_event,
|
|
598
|
-
True,
|
|
599
|
-
)
|
|
600
|
-
if compact_result is None:
|
|
601
|
-
view.write_line("Nothing to compact.")
|
|
602
|
-
return
|
|
603
|
-
view.load_session_history(
|
|
604
|
-
getattr(view, "_title", None),
|
|
605
|
-
conversation_history_to_turns(compact_result.history),
|
|
606
|
-
)
|
|
607
|
-
view.write_line(compact_result.display_text())
|
|
608
|
-
|
|
609
|
-
async def wait_for_turn_result(future) -> 'None':
|
|
610
|
-
try:
|
|
611
|
-
result = await future
|
|
612
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
613
|
-
if str(exc) == "submission interrupted":
|
|
614
|
-
return
|
|
615
|
-
view.show_error(str(exc))
|
|
616
|
-
return
|
|
617
|
-
|
|
618
|
-
if json_mode:
|
|
619
|
-
view.write_line(format_turn_output(result, True))
|
|
620
|
-
|
|
621
|
-
while True:
|
|
622
|
-
try:
|
|
623
|
-
raw_line = await view.poll_prompt()
|
|
624
|
-
except EOFError:
|
|
625
|
-
break
|
|
626
|
-
if raw_line is None:
|
|
627
|
-
await asyncio.sleep(0.05)
|
|
628
|
-
continue
|
|
629
|
-
|
|
630
|
-
prompt_text = raw_line.strip()
|
|
631
|
-
if not prompt_text:
|
|
632
|
-
continue
|
|
633
|
-
if prompt_text in EXIT_COMMANDS:
|
|
634
|
-
break
|
|
635
|
-
if prompt_text == HISTORY_COMMAND:
|
|
636
|
-
view.show_history()
|
|
637
|
-
continue
|
|
638
|
-
if prompt_text == TITLE_COMMAND:
|
|
639
|
-
view.show_title()
|
|
640
|
-
continue
|
|
641
|
-
if prompt_text == RESUME_COMMAND:
|
|
642
|
-
sessions = list_resumable_sessions(codex_home)
|
|
643
|
-
if not sessions:
|
|
644
|
-
view.write_line("No resumable sessions found.")
|
|
645
|
-
continue
|
|
646
|
-
view.write_line("Available sessions:")
|
|
647
|
-
for index, session in enumerate(sessions, start=1):
|
|
648
|
-
view.write_line(f"[{index}] {session['preview']}")
|
|
649
|
-
continue
|
|
650
|
-
if prompt_text.startswith(f"{RESUME_COMMAND} "):
|
|
651
|
-
if has_pending_turn_tasks():
|
|
652
|
-
view.write_line(
|
|
653
|
-
"Cannot resume while work is running or queued."
|
|
654
|
-
)
|
|
655
|
-
continue
|
|
656
|
-
resume_target = prompt_text[len(RESUME_COMMAND) :].strip()
|
|
657
|
-
try:
|
|
658
|
-
resumed = load_resumed_session(codex_home, resume_target)
|
|
659
|
-
queue._agent.replace_history(resumed["history"])
|
|
660
|
-
if hasattr(model_client, "_session_id"):
|
|
661
|
-
model_client._session_id = str(resumed["session_id"])
|
|
662
|
-
queue._agent.set_rollout_recorder(
|
|
663
|
-
SessionRolloutRecorder.resume(resumed["rollout_path"])
|
|
664
|
-
)
|
|
665
|
-
view.load_session_history(
|
|
666
|
-
str(resumed["title"]),
|
|
667
|
-
tuple(resumed["turns"]),
|
|
668
|
-
)
|
|
669
|
-
view.write_line(f"Resumed session: {resumed['title']}")
|
|
670
|
-
view.show_history()
|
|
671
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
672
|
-
view.show_error(str(exc))
|
|
673
|
-
continue
|
|
674
|
-
if prompt_text == COMPACT_COMMAND:
|
|
675
|
-
if has_pending_turn_tasks():
|
|
676
|
-
view.write_line(
|
|
677
|
-
"Cannot compact while work is running or queued."
|
|
678
|
-
)
|
|
679
|
-
continue
|
|
680
|
-
try:
|
|
681
|
-
await run_manual_compact()
|
|
682
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
683
|
-
view.show_error(str(exc))
|
|
684
|
-
continue
|
|
685
|
-
if prompt_text.startswith(f"{LINK_COMMAND} "):
|
|
686
|
-
link_target = prompt_text[len(LINK_COMMAND) :].strip()
|
|
687
|
-
if not link_target:
|
|
688
|
-
view.write_line("Usage: /link <feishu-email|open_id|chat_id>")
|
|
689
|
-
continue
|
|
690
|
-
if feishu_link:
|
|
691
|
-
view.write_line("A Feishu card is already linked. Use /unlink first.")
|
|
692
|
-
continue
|
|
693
|
-
try:
|
|
694
|
-
from .feishu_link import PycodexRuntimeLink
|
|
695
|
-
|
|
696
|
-
view.write_line(f"Linking Feishu card to current session: {link_target}")
|
|
697
|
-
link = await PycodexRuntimeLink(
|
|
698
|
-
queue,
|
|
699
|
-
link_target,
|
|
700
|
-
).start_async()
|
|
701
|
-
feishu_link = link
|
|
702
|
-
view.write_line(
|
|
703
|
-
"Linked Feishu card: session_key={0} message_id={1}".format(
|
|
704
|
-
link.session_key,
|
|
705
|
-
link.message_id or "-",
|
|
706
|
-
)
|
|
707
|
-
)
|
|
708
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
709
|
-
view.show_error(str(exc))
|
|
710
|
-
continue
|
|
711
|
-
if prompt_text == UNLINK_COMMAND:
|
|
712
|
-
if not feishu_link:
|
|
713
|
-
view.write_line("No Feishu card is linked.")
|
|
714
|
-
continue
|
|
715
|
-
feishu_link.detach()
|
|
716
|
-
feishu_link = None
|
|
717
|
-
view.write_line("Unlinked Feishu card.")
|
|
718
|
-
continue
|
|
719
|
-
if prompt_text.startswith(f"{QUEUE_COMMAND} "):
|
|
720
|
-
queued_text = prompt_text[len(QUEUE_COMMAND) :].strip()
|
|
721
|
-
if not queued_text:
|
|
722
|
-
view.write_line("Usage: /queue <message>")
|
|
723
|
-
continue
|
|
724
|
-
try:
|
|
725
|
-
submission_id, future = await queue.enqueue_user_turn(
|
|
726
|
-
queued_text, queue="enqueue"
|
|
727
|
-
)
|
|
728
|
-
view.show_steer_queued(submission_id, queued_text)
|
|
729
|
-
turn_task = asyncio.create_task(wait_for_turn_result(future))
|
|
730
|
-
pending_turn_tasks.add(turn_task)
|
|
731
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
732
|
-
view.show_error(str(exc))
|
|
733
|
-
continue
|
|
734
|
-
if prompt_text == MODEL_COMMAND:
|
|
735
|
-
view.write_line(
|
|
736
|
-
f"Current model: {getattr(model_client, 'model', None) or 'unavailable'}"
|
|
737
|
-
)
|
|
738
|
-
models = await model_client.list_models()
|
|
739
|
-
view.write_line(f"Available models: {', '.join(models)}")
|
|
740
|
-
continue
|
|
741
|
-
if prompt_text.startswith(f"{MODEL_COMMAND} "):
|
|
742
|
-
if has_pending_turn_tasks():
|
|
743
|
-
view.write_line(
|
|
744
|
-
"Cannot change model while work is running or queued in steer mode."
|
|
745
|
-
)
|
|
746
|
-
continue
|
|
747
|
-
model_name = prompt_text[len(MODEL_COMMAND) :].strip()
|
|
748
|
-
if not model_name:
|
|
749
|
-
view.write_line("Usage: /model <model>")
|
|
750
|
-
continue
|
|
751
|
-
|
|
752
|
-
model_client.model = model_name
|
|
753
|
-
view.write_line(f"Switched model to {model_name}.")
|
|
754
|
-
continue
|
|
755
|
-
|
|
756
|
-
try:
|
|
757
|
-
steered = has_pending_turn_tasks()
|
|
758
|
-
submission_id, future = await queue.enqueue_user_turn(
|
|
759
|
-
prompt_text,
|
|
760
|
-
queue="steer",
|
|
761
|
-
)
|
|
762
|
-
if steered:
|
|
763
|
-
view.schedule_steer_inserted(submission_id, prompt_text)
|
|
764
|
-
turn_task = asyncio.create_task(wait_for_turn_result(future))
|
|
765
|
-
pending_turn_tasks.add(turn_task)
|
|
766
|
-
continue
|
|
767
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
768
|
-
view.show_error(str(exc))
|
|
769
|
-
continue
|
|
770
|
-
finally:
|
|
771
|
-
if feishu_link:
|
|
772
|
-
feishu_link.detach()
|
|
773
|
-
feishu_link.stop()
|
|
774
|
-
runtime_environment.request_user_input_manager.set_handler(None)
|
|
775
|
-
runtime_environment.request_permissions_manager.set_handler(None)
|
|
776
|
-
await queue.shutdown()
|
|
777
|
-
await worker
|
|
778
|
-
if pending_turn_tasks:
|
|
779
|
-
await asyncio.gather(*pending_turn_tasks, return_exceptions=True)
|
|
780
|
-
view.close()
|
|
781
|
-
|
|
782
|
-
return 0
|
|
783
443
|
|
|
784
444
|
|
|
785
445
|
async def run_cli(args: 'argparse.Namespace') -> 'int':
|
pycodex/context.py
CHANGED
|
@@ -149,6 +149,7 @@ class ContextManager:
|
|
|
149
149
|
include_permissions_instructions: 'bool' = True,
|
|
150
150
|
include_skills_instructions: 'bool' = True,
|
|
151
151
|
network_access: 'str' = "enabled",
|
|
152
|
+
extra_contextual_user_messages: 'typing.Iterable[str]' = (),
|
|
152
153
|
) -> 'None':
|
|
153
154
|
self.cwd = Path.cwd().resolve()
|
|
154
155
|
self._shell = get_shell_name()
|
|
@@ -166,6 +167,14 @@ class ContextManager:
|
|
|
166
167
|
self._include_permissions_instructions = include_permissions_instructions
|
|
167
168
|
self._include_skills_instructions = include_skills_instructions
|
|
168
169
|
self._network_access = network_access
|
|
170
|
+
self._extra_contextual_user_messages = tuple(
|
|
171
|
+
text
|
|
172
|
+
for text in (
|
|
173
|
+
_normalize_text(message)
|
|
174
|
+
for message in extra_contextual_user_messages
|
|
175
|
+
)
|
|
176
|
+
if text is not None
|
|
177
|
+
)
|
|
169
178
|
self._default_base_instructions = DEFAULT_BASE_INSTRUCTIONS_PATH.read_text(
|
|
170
179
|
encoding="utf-8"
|
|
171
180
|
)
|
|
@@ -183,6 +192,7 @@ class ContextManager:
|
|
|
183
192
|
include_permissions_instructions: 'bool' = True,
|
|
184
193
|
include_skills_instructions: 'bool' = True,
|
|
185
194
|
network_access: 'str' = "enabled",
|
|
195
|
+
extra_contextual_user_messages: 'typing.Iterable[str]' = (),
|
|
186
196
|
) -> 'ContextManager':
|
|
187
197
|
config = ContextConfig.from_codex_config(config_path, profile)
|
|
188
198
|
return cls(
|
|
@@ -193,6 +203,7 @@ class ContextManager:
|
|
|
193
203
|
include_permissions_instructions=include_permissions_instructions,
|
|
194
204
|
include_skills_instructions=include_skills_instructions,
|
|
195
205
|
network_access=network_access,
|
|
206
|
+
extra_contextual_user_messages=extra_contextual_user_messages,
|
|
196
207
|
)
|
|
197
208
|
|
|
198
209
|
@property
|
|
@@ -421,6 +432,7 @@ class ContextManager:
|
|
|
421
432
|
)
|
|
422
433
|
)
|
|
423
434
|
sections.append(self._serialize_environment_context())
|
|
435
|
+
sections.extend(self._extra_contextual_user_messages)
|
|
424
436
|
if not sections:
|
|
425
437
|
return []
|
|
426
438
|
return [
|