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.
Files changed (50) hide show
  1. pycodex/agent.py +71 -11
  2. pycodex/cli.py +16 -356
  3. pycodex/context.py +12 -0
  4. pycodex/feishu_card.py +76 -30
  5. pycodex/feishu_link.py +131 -11
  6. pycodex/interactive_session.py +397 -0
  7. pycodex/model.py +11 -22
  8. pycodex/protocol.py +0 -5
  9. pycodex/runtime.py +23 -0
  10. pycodex/runtime_services.py +2 -2
  11. pycodex/tools/agent_tool_schemas.py +1 -1
  12. pycodex/tools/apply_patch_tool.py +1 -1
  13. pycodex/tools/base_tool.py +1 -27
  14. pycodex/tools/close_agent_tool.py +11 -4
  15. pycodex/tools/code_mode_manager.py +1 -1
  16. pycodex/tools/exec_command_tool.py +40 -16
  17. pycodex/tools/exec_tool.py +18 -2
  18. pycodex/tools/grep_files_tool.py +19 -6
  19. pycodex/tools/ipython_tool.py +3 -2
  20. pycodex/tools/list_dir_tool.py +19 -6
  21. pycodex/tools/read_file_tool.py +39 -9
  22. pycodex/tools/request_permissions_tool.py +12 -1
  23. pycodex/tools/request_user_input_tool.py +28 -1
  24. pycodex/tools/send_input_tool.py +4 -2
  25. pycodex/tools/shell_command_tool.py +23 -6
  26. pycodex/tools/shell_tool.py +13 -4
  27. pycodex/tools/spawn_agent_tool.py +31 -8
  28. pycodex/tools/unified_exec_manager.py +49 -93
  29. pycodex/tools/update_plan_tool.py +14 -6
  30. pycodex/tools/view_image_tool.py +17 -16
  31. pycodex/tools/wait_agent_tool.py +15 -3
  32. pycodex/tools/wait_tool.py +18 -4
  33. pycodex/tools/web_search_tool.py +2 -1
  34. pycodex/tools/write_stdin_tool.py +42 -10
  35. pycodex/utils/compactor.py +7 -1
  36. pycodex/utils/session_persist.py +42 -1
  37. pycodex/utils/truncation.py +206 -0
  38. pycodex/utils/visualize.py +34 -15
  39. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/METADATA +4 -1
  40. python_codex-0.2.0.dist-info/RECORD +88 -0
  41. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/entry_points.txt +1 -0
  42. workspace_server/__init__.py +23 -0
  43. workspace_server/__main__.py +5 -0
  44. workspace_server/app.py +1347 -0
  45. workspace_server/workspace.html +866 -0
  46. pycodex/prompts/exec_tools.json +0 -411
  47. pycodex/prompts/subagent_tools.json +0 -163
  48. python_codex-0.1.13.dist-info/RECORD +0 -84
  49. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/WHEEL +0 -0
  50. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/licenses/LICENSE +0 -0
pycodex/feishu_card.py CHANGED
@@ -45,6 +45,8 @@ class PycodexCard:
45
45
  self.last_prompt = ""
46
46
  self.model_name = "pycodex"
47
47
  self.output_text = ""
48
+ self.working_output_text = ""
49
+ self._working_delta_active = False
48
50
  self.error = ""
49
51
  self.running = False
50
52
  self.detached = False
@@ -150,6 +152,7 @@ class PycodexCard:
150
152
  self.last_sender = sender or "cli"
151
153
  self.last_prompt = prompt
152
154
  self._mark_last_turn_output()
155
+ self._reset_working_output()
153
156
  self.error = ""
154
157
  self.running = True
155
158
 
@@ -179,6 +182,7 @@ class PycodexCard:
179
182
  self.status_detail = "Model request started."
180
183
  was_running = self.running
181
184
  self.running = True
185
+ self._reset_working_output()
182
186
  prompt = payload.get("user_text") or "\n".join(
183
187
  str(item) for item in payload.get("user_texts", []) or []
184
188
  )
@@ -193,16 +197,18 @@ class PycodexCard:
193
197
  if kind == "assistant_delta":
194
198
  self.status = "Responding"
195
199
  self.status_detail = "Receiving assistant output."
196
- # self.output_text += str(payload.get("delta", ""))
200
+ self._append_working_delta(str(payload.get("delta") or ""))
197
201
  return False
198
202
  if kind == "tool_started":
199
203
  self.status = "Tool"
200
204
  self.status_detail = str(payload.get("tool_name") or "tool")
205
+ self._finish_working_delta_segment()
201
206
  return False
202
207
  if kind == "tool_completed":
203
208
  self.status_detail = str(
204
209
  payload.get("summary") or payload.get("tool_name") or "tool completed"
205
210
  )
211
+ self._finish_working_delta_segment()
206
212
  return False
207
213
  if kind == "stream_error":
208
214
  self.status = "Retrying"
@@ -217,17 +223,20 @@ class PycodexCard:
217
223
  self.status = "Idle"
218
224
  self.status_detail = "Turn completed."
219
225
  self.running = False
226
+ self._reset_working_output()
220
227
  return True
221
228
  if kind in {"turn_failed", "submission_failed"}:
222
229
  self.status = "Error"
223
230
  self.error = str(payload.get("error") or kind)
224
231
  self.status_detail = self.error
225
232
  self.running = False
233
+ self._finish_working_delta_segment()
226
234
  return True
227
235
  if kind in {"turn_interrupted", "submission_cancelled"}:
228
236
  self.status = "Idle"
229
237
  self.status_detail = kind.replace("_", " ")
230
238
  self.running = False
239
+ self._reset_working_output()
231
240
  return True
232
241
  return False
233
242
 
@@ -235,6 +244,22 @@ class PycodexCard:
235
244
  if self.output_text and not self.output_text.startswith(LAST_TURN_PREFIX):
236
245
  self.output_text = LAST_TURN_PREFIX + self.output_text
237
246
 
247
+ def _reset_working_output(self) -> None:
248
+ self.working_output_text = ""
249
+ self._working_delta_active = False
250
+
251
+ def _finish_working_delta_segment(self) -> None:
252
+ self._working_delta_active = False
253
+
254
+ def _append_working_delta(self, delta: str) -> None:
255
+ if not delta:
256
+ return
257
+ if self._working_delta_active:
258
+ self.working_output_text += delta
259
+ else:
260
+ self.working_output_text = delta
261
+ self._working_delta_active = True
262
+
238
263
  def render(
239
264
  self, output_mode: str = CARD_OUTPUT_MODE_MARKDOWN
240
265
  ) -> "typing.Dict[str, object]":
@@ -251,7 +276,25 @@ class PycodexCard:
251
276
  "Waiting for output..." if self.running else "Ready."
252
277
  )
253
278
  output_content = _render_output_content(output, output_mode)
279
+ working_output = _truncate(self.working_output_text, CARD_OUTPUT_LIMIT)
254
280
  color, title = _status_template(self.status)
281
+ body_elements = [
282
+ {
283
+ "tag": "markdown",
284
+ "element_id": "prompt_md",
285
+ "content": f"> {sender}: **{_escape_code_block(prompt)}**",
286
+ },
287
+ _output_box("answer_box", "answer_md", output_content, "grey-50"),
288
+ ]
289
+ if working_output:
290
+ body_elements.append(
291
+ _output_box(
292
+ "working_output_box",
293
+ "working_output_md",
294
+ _render_output_content(working_output, output_mode),
295
+ "green-50",
296
+ )
297
+ )
255
298
  card = {
256
299
  "schema": "2.0",
257
300
  "config": {"update_multi": True},
@@ -260,35 +303,7 @@ class PycodexCard:
260
303
  "template": color,
261
304
  },
262
305
  "body": {
263
- "elements": [
264
- {
265
- "tag": "markdown",
266
- "element_id": "prompt_md",
267
- "content": f"> {sender}: **{_escape_code_block(prompt)}**",
268
- },
269
- {
270
- "tag": "column_set",
271
- "background_style": "grey-50",
272
- "horizontal_spacing": "8px",
273
- "horizontal_align": "left",
274
- "columns": [
275
- {
276
- "tag": "column",
277
- "width": "auto",
278
- "elements": [
279
- {
280
- "tag": "markdown",
281
- "element_id": "answer_md",
282
- "content": output_content,
283
- },
284
- ],
285
- "vertical_spacing": "8px",
286
- "horizontal_align": "left",
287
- "vertical_align": "top",
288
- }
289
- ],
290
- },
291
- ]
306
+ "elements": body_elements,
292
307
  },
293
308
  }
294
309
  if not self.detached:
@@ -614,6 +629,37 @@ def _render_output_content(output: str, output_mode: str) -> str:
614
629
  return output
615
630
 
616
631
 
632
+ def _output_box(
633
+ box_id: str,
634
+ markdown_id: str,
635
+ content: str,
636
+ background_style: str,
637
+ ) -> "typing.Dict[str, object]":
638
+ return {
639
+ "tag": "column_set",
640
+ "element_id": box_id,
641
+ "background_style": background_style,
642
+ "horizontal_spacing": "8px",
643
+ "horizontal_align": "left",
644
+ "columns": [
645
+ {
646
+ "tag": "column",
647
+ "width": "auto",
648
+ "elements": [
649
+ {
650
+ "tag": "markdown",
651
+ "element_id": markdown_id,
652
+ "content": content,
653
+ },
654
+ ],
655
+ "vertical_spacing": "8px",
656
+ "horizontal_align": "left",
657
+ "vertical_align": "top",
658
+ }
659
+ ],
660
+ }
661
+
662
+
617
663
  def _is_card_content_error(exc: "BaseException") -> bool:
618
664
  return CARD_CONTENT_ERROR_MARKER in str(exc)
619
665
 
pycodex/feishu_link.py CHANGED
@@ -31,6 +31,7 @@ class PycodexRuntimeLink:
31
31
  self._event_handler = None
32
32
  self._detached = False
33
33
  self._update_thread = None
34
+ self._update_stop = threading.Event()
34
35
  self._update_lock = threading.Lock()
35
36
  self._update_pending = False
36
37
 
@@ -60,17 +61,19 @@ class PycodexRuntimeLink:
60
61
  def stop(self) -> None:
61
62
  self._restore_event_handler()
62
63
  if self._listener is not None:
63
- self._listener.unregister(self)
64
+ _release_feishu_listener(self._listener, self)
64
65
  self._listener = None
66
+ self._stop_update_thread()
65
67
 
66
68
  def detach(self) -> None:
67
69
  self._detached = True
68
70
  self._restore_event_handler()
69
71
  if self._listener is not None:
70
- self._listener.unregister(self)
72
+ _release_feishu_listener(self._listener, self)
71
73
  self._listener = None
72
74
  self.card.detach()
73
75
  self._safe_update_card()
76
+ self._stop_update_thread()
74
77
 
75
78
  def _restore_event_handler(self) -> None:
76
79
  current = getattr(self.queue, "_event_handler", None)
@@ -152,6 +155,7 @@ class PycodexRuntimeLink:
152
155
  def _start_update_thread(self) -> None:
153
156
  if self._update_thread is not None and self._update_thread.is_alive():
154
157
  return
158
+ self._update_stop.clear()
155
159
  self._update_thread = threading.Thread(
156
160
  target=self._run_update_loop,
157
161
  name="pycodex-feishu-card",
@@ -159,9 +163,15 @@ class PycodexRuntimeLink:
159
163
  self._update_thread.daemon = True
160
164
  self._update_thread.start()
161
165
 
166
+ def _stop_update_thread(self) -> None:
167
+ self._update_stop.set()
168
+ thread = self._update_thread
169
+ if thread is not None and thread.is_alive():
170
+ thread.join(timeout=2.0)
171
+ self._update_thread = None
172
+
162
173
  def _run_update_loop(self) -> None:
163
- while True:
164
- time.sleep(CARD_UPDATE_FLUSH_INTERVAL_SECONDS)
174
+ while not self._update_stop.wait(CARD_UPDATE_FLUSH_INTERVAL_SECONDS):
165
175
  with self._update_lock:
166
176
  if not self._update_pending:
167
177
  continue
@@ -194,6 +204,19 @@ def _feishu_listener(card: PycodexCard) -> '_FeishuCardActionListener':
194
204
  return _LISTENER
195
205
 
196
206
 
207
+ def _release_feishu_listener(
208
+ listener: '_FeishuCardActionListener',
209
+ link: PycodexRuntimeLink,
210
+ ) -> None:
211
+ global _LISTENER
212
+ with _LISTENER_LOCK:
213
+ listener.unregister(link)
214
+ if listener.empty():
215
+ listener.stop()
216
+ if _LISTENER is listener:
217
+ _LISTENER = None
218
+
219
+
197
220
  class _FeishuCardActionListener:
198
221
  def __init__(self, card: PycodexCard) -> None:
199
222
  self.app_id = card.app_id
@@ -204,6 +227,9 @@ class _FeishuCardActionListener:
204
227
  self.link = None
205
228
  self._async_thread = _AsyncLoopThread()
206
229
  self._thread = None
230
+ self._client = None
231
+ self._client_loop = None
232
+ self._stop_requested = threading.Event()
207
233
 
208
234
  def assert_compatible(self, card: PycodexCard) -> None:
209
235
  if self.app_id != card.app_id or self.domain != card.domain:
@@ -214,6 +240,7 @@ class _FeishuCardActionListener:
214
240
  raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
215
241
  if self._thread is not None and self._thread.is_alive():
216
242
  return
243
+ self._stop_requested.clear()
217
244
  self._async_thread.start()
218
245
  self._thread = threading.Thread(
219
246
  target=self._listen,
@@ -229,6 +256,21 @@ class _FeishuCardActionListener:
229
256
  if self.link is link:
230
257
  self.link = None
231
258
 
259
+ def empty(self) -> bool:
260
+ return self.link is None
261
+
262
+ def stop(self) -> None:
263
+ self._stop_requested.set()
264
+ client = self._client
265
+ loop = self._client_loop
266
+ if loop is not None:
267
+ _stop_lark_client(client, loop)
268
+ thread = self._thread
269
+ if thread is not None and thread.is_alive():
270
+ thread.join(timeout=2.0)
271
+ self._thread = None
272
+ self._async_thread.stop()
273
+
232
274
  def _listen(self) -> None:
233
275
  try:
234
276
  import lark_oapi as lark
@@ -253,13 +295,37 @@ class _FeishuCardActionListener:
253
295
  .register_p2_card_action_trigger(on_card_action)
254
296
  .build()
255
297
  )
256
- client = lark_ws.Client(
257
- self.app_id,
258
- self.app_secret,
259
- event_handler=handler,
260
- domain=self.domain,
261
- )
262
- client.start()
298
+ import lark_oapi.ws.client as lark_ws_client
299
+
300
+ client = None
301
+ loop = asyncio.new_event_loop()
302
+ self._client_loop = loop
303
+ asyncio.set_event_loop(loop)
304
+ lark_ws_client.loop = loop
305
+ try:
306
+ if self._stop_requested.is_set():
307
+ return
308
+ client = lark_ws.Client(
309
+ self.app_id,
310
+ self.app_secret,
311
+ event_handler=handler,
312
+ domain=self.domain,
313
+ )
314
+ self._client = client
315
+ if self._stop_requested.is_set():
316
+ return
317
+ client.start()
318
+ except RuntimeError as exc:
319
+ stopped = "Event loop stopped before Future completed" in str(exc)
320
+ if not self._stop_requested.is_set() or not stopped:
321
+ raise
322
+ finally:
323
+ if self._client is client:
324
+ self._client = None
325
+ if self._client_loop is loop:
326
+ self._client_loop = None
327
+ _close_loop(loop)
328
+ asyncio.set_event_loop(None)
263
329
 
264
330
  def _handle_card_action(self, event) -> 'typing.Dict[str, object]':
265
331
  try:
@@ -300,6 +366,60 @@ def _event_message_id(event) -> "typing.Union[str, None]":
300
366
  return None
301
367
 
302
368
 
369
+ def _stop_lark_client(client, loop) -> None:
370
+ if loop.is_closed():
371
+ return
372
+ disconnect = getattr(client, "_disconnect", None) if client is not None else None
373
+ if loop.is_running():
374
+ future = asyncio.run_coroutine_threadsafe(
375
+ _disconnect_then_stop(disconnect, loop),
376
+ loop,
377
+ )
378
+ try:
379
+ future.result(timeout=2.0)
380
+ except Exception as exc:
381
+ future.cancel()
382
+ print(exc)
383
+ loop.call_soon_threadsafe(loop.stop)
384
+ return
385
+ if callable(disconnect):
386
+ try:
387
+ loop.run_until_complete(disconnect())
388
+ except Exception as exc:
389
+ print(exc)
390
+
391
+
392
+ async def _disconnect_then_stop(disconnect, loop) -> None:
393
+ try:
394
+ if callable(disconnect):
395
+ await disconnect()
396
+ except Exception as exc:
397
+ print(exc)
398
+ finally:
399
+ loop.stop()
400
+
401
+
402
+ def _close_loop(loop) -> None:
403
+ if loop.is_closed():
404
+ return
405
+ tasks = list(_all_loop_tasks(loop))
406
+ for task in tasks:
407
+ task.cancel()
408
+ if tasks:
409
+ loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
410
+ shutdown_asyncgens = getattr(loop, "shutdown_asyncgens", None)
411
+ if callable(shutdown_asyncgens):
412
+ loop.run_until_complete(shutdown_asyncgens())
413
+ loop.close()
414
+
415
+
416
+ def _all_loop_tasks(loop):
417
+ all_tasks = getattr(asyncio, "all_tasks", None)
418
+ if callable(all_tasks):
419
+ return all_tasks(loop)
420
+ return asyncio.Task.all_tasks(loop)
421
+
422
+
303
423
  class _AsyncLoopThread:
304
424
  def __init__(self) -> None:
305
425
  self.loop = None