zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,522 @@
1
+ """Per-turn end-status summary.
2
+
3
+ Records why a single agent turn ended and how long it ran, and always
4
+ emits a compact marker after the turn — no matter how the turn ended
5
+ (normal reply, truncated stream, tool error, user Ctrl-C, provider timeout).
6
+
7
+ Sibling `session_end_summary` covers process-level shutdown. This module
8
+ covers the per-turn level, where "the answer just cut off" happens while
9
+ the dcode process is still alive.
10
+
11
+ Design constraints:
12
+ - **Zero configuration**: always on; no env vars, no toml switch.
13
+ - **Never raises**: any I/O or formatting failure is logged at debug/warning
14
+ and swallowed. Missing a marker must never mask the real exit code.
15
+ - **Idempotent per turn**: `finalize_turn` may fire more than once
16
+ (finally-blocks stacked with explicit calls); only the first invocation
17
+ between `mark_turn_start`/`finalize_turn` writes anything.
18
+ - **Cheap import**: no heavy dependencies at import time.
19
+
20
+ Sinks (best-effort, independent):
21
+
22
+ 1. TUI / console — the caller mounts `render_marker_text(record)` after
23
+ the assistant reply (or prints it to stderr in headless mode).
24
+ 2. `~/.deepagents/turn_end_log.jsonl` — one JSON line per turn.
25
+ 3. `<project_root>/doc/YYYY-MM-DD-*.md` — when a `doc/` folder exists in
26
+ the project root, append a status line to the most recently modified
27
+ doc file for the current day. Silent no-op if no such folder or file
28
+ exists.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import logging
35
+ import os
36
+ import threading
37
+ import time
38
+ from dataclasses import dataclass, field
39
+ from datetime import datetime
40
+ from pathlib import Path
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ # --------------------------------------------------------------------------- #
46
+ # Reason constants
47
+ # --------------------------------------------------------------------------- #
48
+
49
+ REASON_COMPLETED = "completed"
50
+ REASON_LENGTH_CAPPED = "length_capped"
51
+ REASON_CONTENT_FILTERED = "content_filtered"
52
+ REASON_TOOL_REJECTED = "tool_rejected"
53
+ REASON_USER_INTERRUPTED = "user_interrupted"
54
+ REASON_STREAM_ERROR = "stream_error"
55
+ REASON_PROVIDER_ERROR = "provider_error"
56
+ REASON_TIMEOUT = "timeout"
57
+ REASON_RECURSION_LIMIT = "recursion_limit"
58
+ REASON_UNKNOWN_TRUNCATION = "unknown_truncation"
59
+
60
+ ALL_REASONS: frozenset[str] = frozenset(
61
+ {
62
+ REASON_COMPLETED,
63
+ REASON_LENGTH_CAPPED,
64
+ REASON_CONTENT_FILTERED,
65
+ REASON_TOOL_REJECTED,
66
+ REASON_USER_INTERRUPTED,
67
+ REASON_STREAM_ERROR,
68
+ REASON_PROVIDER_ERROR,
69
+ REASON_TIMEOUT,
70
+ REASON_RECURSION_LIMIT,
71
+ REASON_UNKNOWN_TRUNCATION,
72
+ }
73
+ )
74
+
75
+
76
+ _REASON_PRIORITY: dict[str, int] = {
77
+ # `unknown_truncation` is the default and the least informative — any
78
+ # explicit signal should be able to upgrade over it.
79
+ REASON_UNKNOWN_TRUNCATION: 0,
80
+ REASON_COMPLETED: 1,
81
+ REASON_LENGTH_CAPPED: 2,
82
+ REASON_CONTENT_FILTERED: 2,
83
+ REASON_TOOL_REJECTED: 3,
84
+ REASON_TIMEOUT: 3,
85
+ REASON_PROVIDER_ERROR: 3,
86
+ REASON_RECURSION_LIMIT: 3,
87
+ REASON_STREAM_ERROR: 4,
88
+ REASON_USER_INTERRUPTED: 5,
89
+ }
90
+
91
+
92
+ _REASON_EMOJI: dict[str, str] = {
93
+ REASON_COMPLETED: "\u2705",
94
+ REASON_LENGTH_CAPPED: "\u2702\ufe0f",
95
+ REASON_CONTENT_FILTERED: "\U0001f6e1",
96
+ REASON_TOOL_REJECTED: "\U0001f6ab",
97
+ REASON_USER_INTERRUPTED: "\u23f8",
98
+ REASON_STREAM_ERROR: "\u26a0\ufe0f",
99
+ REASON_PROVIDER_ERROR: "\u26a0\ufe0f",
100
+ REASON_TIMEOUT: "\u23f1",
101
+ REASON_RECURSION_LIMIT: "\U0001f501",
102
+ REASON_UNKNOWN_TRUNCATION: "\u2754",
103
+ }
104
+
105
+
106
+ _REASON_LABEL: dict[str, str] = {
107
+ REASON_COMPLETED: "\u6b63\u5e38\u7ed3\u675f",
108
+ REASON_LENGTH_CAPPED: "\u8f93\u51fa\u88ab\u622a\u65ad(max_tokens)",
109
+ REASON_CONTENT_FILTERED: "\u5185\u5bb9\u88ab\u8fc7\u6ee4",
110
+ REASON_TOOL_REJECTED: "\u5de5\u5177\u88ab\u62d2\u7edd",
111
+ REASON_USER_INTERRUPTED: "\u7528\u6237\u4e2d\u65ad",
112
+ REASON_STREAM_ERROR: "\u6d41\u5f02\u5e38",
113
+ REASON_PROVIDER_ERROR: "\u63d0\u4f9b\u65b9\u9519\u8bef",
114
+ REASON_TIMEOUT: "\u8d85\u65f6",
115
+ REASON_RECURSION_LIMIT: "\u9012\u5f52\u4e0a\u9650",
116
+ REASON_UNKNOWN_TRUNCATION: "\u7591\u4f3c\u622a\u65ad(\u65e0 finish_reason)",
117
+ }
118
+
119
+
120
+ _FINISH_REASON_MAP: dict[str, str] = {
121
+ "stop": REASON_COMPLETED,
122
+ "end_turn": REASON_COMPLETED,
123
+ "complete": REASON_COMPLETED,
124
+ "finished": REASON_COMPLETED,
125
+ "eos": REASON_COMPLETED,
126
+ "length": REASON_LENGTH_CAPPED,
127
+ "max_tokens": REASON_LENGTH_CAPPED,
128
+ "model_length": REASON_LENGTH_CAPPED,
129
+ "model_max_tokens": REASON_LENGTH_CAPPED,
130
+ "output_length": REASON_LENGTH_CAPPED,
131
+ "max_output_tokens": REASON_LENGTH_CAPPED,
132
+ "content_filter": REASON_CONTENT_FILTERED,
133
+ "safety": REASON_CONTENT_FILTERED,
134
+ "blocked": REASON_CONTENT_FILTERED,
135
+ }
136
+
137
+
138
+ _TOOL_CALL_FINISH_REASONS = frozenset({"tool_calls", "tool_use", "function_call"})
139
+
140
+
141
+ # --------------------------------------------------------------------------- #
142
+ # Module state
143
+ # --------------------------------------------------------------------------- #
144
+
145
+ _state_lock = threading.RLock()
146
+
147
+
148
+ @dataclass
149
+ class _TurnState:
150
+ start_monotonic: float | None = None
151
+ thread_id: str = ""
152
+ turn_id: str = ""
153
+ turn_number: int | None = None
154
+ reason: str = REASON_UNKNOWN_TRUNCATION
155
+ detail: str = ""
156
+ finish_reason_raw: str = ""
157
+ finalized: bool = False
158
+ started: bool = False
159
+
160
+
161
+ _current: _TurnState = _TurnState()
162
+
163
+
164
+ @dataclass
165
+ class TurnEndRecord:
166
+ """Immutable snapshot returned by `finalize_turn`."""
167
+
168
+ reason: str
169
+ detail: str
170
+ duration_seconds: float
171
+ duration_pretty: str
172
+ thread_id: str
173
+ turn_id: str
174
+ turn_number: int | None
175
+ finish_reason_raw: str
176
+ ended_at: float = field(default_factory=time.time)
177
+
178
+
179
+ # --------------------------------------------------------------------------- #
180
+ # Public API — state mutation
181
+ # --------------------------------------------------------------------------- #
182
+
183
+
184
+ def mark_turn_start(
185
+ *,
186
+ thread_id: str = "",
187
+ turn_id: str | None = None,
188
+ turn_number: int | None = None,
189
+ ) -> None:
190
+ """Reset per-turn state and record the start time."""
191
+ global _current
192
+ with _state_lock:
193
+ _current = _TurnState()
194
+ _current.started = True
195
+ _current.start_monotonic = time.monotonic()
196
+ _current.thread_id = thread_id or ""
197
+ _current.turn_id = turn_id or ""
198
+ _current.turn_number = turn_number
199
+
200
+
201
+ def classify_finish_reason(finish_reason: str | None) -> str | None:
202
+ """Classify a raw provider `finish_reason` into a normalized reason.
203
+
204
+ Returns `None` for tool-call handoff or unrecognized values.
205
+ """
206
+ if not finish_reason or not isinstance(finish_reason, str):
207
+ return None
208
+ normalized = finish_reason.strip().lower()
209
+ if not normalized or normalized in _TOOL_CALL_FINISH_REASONS:
210
+ return None
211
+ return _FINISH_REASON_MAP.get(normalized)
212
+
213
+
214
+ def observe_finish_reason(finish_reason: str | None) -> None:
215
+ """Update the classified reason from a raw `finish_reason` observation."""
216
+ if not finish_reason or not isinstance(finish_reason, str):
217
+ return
218
+ with _state_lock:
219
+ _current.finish_reason_raw = finish_reason
220
+ classified = classify_finish_reason(finish_reason)
221
+ if classified is not None:
222
+ mark_turn_reason(classified, detail=f"finish_reason={finish_reason}")
223
+
224
+
225
+ def mark_turn_reason(reason: str, detail: str = "") -> None:
226
+ """Upgrade the current turn's reason if `reason` has higher priority."""
227
+ if reason not in _REASON_PRIORITY:
228
+ return
229
+ with _state_lock:
230
+ if _REASON_PRIORITY[reason] >= _REASON_PRIORITY[_current.reason]:
231
+ _current.reason = reason
232
+ if detail:
233
+ _current.detail = detail
234
+
235
+
236
+ def classify_exception(exc: BaseException) -> tuple[str, str]:
237
+ """Classify an in-flight exception into a `(reason, detail)` pair.
238
+
239
+ Uses only class name + message so this module stays free of heavy imports.
240
+ """
241
+ name = type(exc).__name__
242
+ msg = str(exc).strip()
243
+ detail = f"{name}: {msg}" if msg else name
244
+ if len(detail) > 500:
245
+ detail = detail[:497] + "..."
246
+
247
+ if isinstance(exc, KeyboardInterrupt):
248
+ return REASON_USER_INTERRUPTED, "KeyboardInterrupt"
249
+ try:
250
+ import asyncio
251
+
252
+ if isinstance(exc, asyncio.CancelledError):
253
+ return REASON_USER_INTERRUPTED, "CancelledError"
254
+ except Exception:
255
+ pass
256
+
257
+ lowered = name.lower()
258
+ if "timeout" in lowered:
259
+ return REASON_TIMEOUT, detail
260
+ if "graphrecursion" in lowered or "recursionerror" in lowered:
261
+ return REASON_RECURSION_LIMIT, detail
262
+ if "httpstatus" in lowered or "clientresponse" in lowered:
263
+ return REASON_PROVIDER_ERROR, detail
264
+ if "connect" in lowered or "transport" in lowered or "network" in lowered:
265
+ return REASON_PROVIDER_ERROR, detail
266
+ return REASON_STREAM_ERROR, detail
267
+
268
+
269
+ def is_active() -> bool:
270
+ """Return whether a turn has been started but not yet finalized."""
271
+ with _state_lock:
272
+ return _current.started and not _current.finalized
273
+
274
+
275
+ # --------------------------------------------------------------------------- #
276
+ # Public API — finalize
277
+ # --------------------------------------------------------------------------- #
278
+
279
+
280
+ def _format_duration(seconds: float) -> str:
281
+ """Format a duration; try `formatting.format_duration`, fall back locally."""
282
+ try:
283
+ from deepagents_code.formatting import format_duration
284
+
285
+ return format_duration(seconds)
286
+ except Exception:
287
+ rounded = round(seconds, 1)
288
+ if rounded < 60:
289
+ return f"{rounded:.1f}s" if rounded % 1 else f"{int(rounded)}s"
290
+ total = int(seconds)
291
+ h, rem = divmod(total, 3600)
292
+ m, s = divmod(rem, 60)
293
+ if h:
294
+ return f"{h}h{m:02d}m{s:02d}s"
295
+ return f"{m}m{s:02d}s"
296
+
297
+
298
+ def finalize_turn() -> TurnEndRecord | None:
299
+ """Emit sinks and return the record. Idempotent.
300
+
301
+ Returns `None` if no turn was ever started, or if the turn has already
302
+ been finalized (double-finalize is a no-op).
303
+ """
304
+ with _state_lock:
305
+ if not _current.started or _current.finalized:
306
+ return None
307
+ _current.finalized = True
308
+ started = _current.start_monotonic
309
+ reason = _current.reason
310
+ detail = _current.detail
311
+ thread_id = _current.thread_id
312
+ turn_id = _current.turn_id
313
+ turn_number = _current.turn_number
314
+ finish_raw = _current.finish_reason_raw
315
+
316
+ duration = time.monotonic() - started if started is not None else 0.0
317
+ record = TurnEndRecord(
318
+ reason=reason,
319
+ detail=detail,
320
+ duration_seconds=round(duration, 3),
321
+ duration_pretty=_format_duration(duration),
322
+ thread_id=thread_id,
323
+ turn_id=turn_id,
324
+ turn_number=turn_number,
325
+ finish_reason_raw=finish_raw,
326
+ )
327
+
328
+ # Sink 2: JSONL audit log — always attempted.
329
+ try:
330
+ _append_jsonl(record)
331
+ except Exception:
332
+ logger.debug("turn_end_summary JSONL append failed", exc_info=True)
333
+
334
+ # Sink 3: doc/ auto-append — best-effort, silent when doc/ absent.
335
+ try:
336
+ _append_to_doc(record)
337
+ except Exception:
338
+ logger.debug("turn_end_summary doc append failed", exc_info=True)
339
+
340
+ return record
341
+
342
+
343
+ # --------------------------------------------------------------------------- #
344
+ # Public API — rendering
345
+ # --------------------------------------------------------------------------- #
346
+
347
+
348
+ def render_marker_text(record: TurnEndRecord) -> str:
349
+ """Render the single-line human-readable marker.
350
+
351
+ Format:
352
+ "⏹ 结束状态:<emoji> <label>[ (detail)] | ⏱ 总耗时:<pretty> | 🕒 结束时间:<ts>"
353
+ """
354
+ emoji = _REASON_EMOJI.get(record.reason, "\u2754")
355
+ label = _REASON_LABEL.get(record.reason, record.reason)
356
+ reason_part = f"{emoji} {label}"
357
+ if record.detail:
358
+ reason_part = f"{reason_part} ({record.detail})"
359
+ ended_at = datetime.fromtimestamp(record.ended_at).strftime("%Y-%m-%d %H:%M:%S")
360
+ return (
361
+ f"\u23f9 \u7ed3\u675f\u72b6\u6001\uff1a{reason_part} | "
362
+ f"\u23f1 \u603b\u8017\u65f6\uff1a{record.duration_pretty} | "
363
+ f"\U0001f552 \u7ed3\u675f\u65f6\u95f4\uff1a{ended_at}"
364
+ )
365
+
366
+
367
+ def render_marker_line(record: TurnEndRecord) -> str:
368
+ """Marker prefixed with `> ` for Markdown-blockquote appending."""
369
+ return f"> {render_marker_text(record)}"
370
+
371
+
372
+ # --------------------------------------------------------------------------- #
373
+ # Sinks
374
+ # --------------------------------------------------------------------------- #
375
+
376
+
377
+ def _log_path() -> Path:
378
+ """`~/.deepagents/turn_end_log.jsonl`, parent created if missing."""
379
+ from deepagents_code.model_config import DEFAULT_CONFIG_DIR
380
+
381
+ DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
382
+ return DEFAULT_CONFIG_DIR / "turn_end_log.jsonl"
383
+
384
+
385
+ def _append_jsonl(record: TurnEndRecord) -> None:
386
+ payload = {
387
+ "ts": record.ended_at,
388
+ "thread_id": record.thread_id,
389
+ "turn_id": record.turn_id,
390
+ "turn_number": record.turn_number,
391
+ "pid": os.getpid(),
392
+ "reason": record.reason,
393
+ "reason_detail": record.detail,
394
+ "finish_reason_raw": record.finish_reason_raw,
395
+ "duration_seconds": record.duration_seconds,
396
+ "duration_pretty": record.duration_pretty,
397
+ }
398
+ # POSIX atomic append: one `write` on a small line stays below PIPE_BUF.
399
+ with _log_path().open("a", encoding="utf-8") as fp:
400
+ fp.write(json.dumps(payload, ensure_ascii=False) + "\n")
401
+
402
+
403
+ # --------------------------------------------------------------------------- #
404
+ # Sink: doc/ auto-append (project-root detected, silent fallback)
405
+ # --------------------------------------------------------------------------- #
406
+
407
+ # Files a directory must contain to qualify as a project root. Order matters
408
+ # only for readability; any single hit anchors the root.
409
+ _PROJECT_ROOT_MARKERS: tuple[str, ...] = (
410
+ "AGENTS.md",
411
+ ".git",
412
+ "pyproject.toml",
413
+ "package.json",
414
+ "Cargo.toml",
415
+ "go.mod",
416
+ )
417
+
418
+
419
+ def _find_project_root(start: Path | None = None) -> Path | None:
420
+ """Walk upward from `start` until a project-root marker is found.
421
+
422
+ Returns `None` if no marker is found before the filesystem root. Uses a
423
+ bounded upward walk to avoid pathological infinite loops in tests.
424
+ """
425
+ try:
426
+ cur = (start or Path.cwd()).resolve()
427
+ except Exception:
428
+ return None
429
+ # Bound the walk. 40 levels is deeper than any realistic project path.
430
+ for _ in range(40):
431
+ for marker in _PROJECT_ROOT_MARKERS:
432
+ if (cur / marker).exists():
433
+ return cur
434
+ if cur.parent == cur:
435
+ return None
436
+ cur = cur.parent
437
+ return None
438
+
439
+
440
+ def _pick_today_doc(doc_dir: Path) -> Path | None:
441
+ """Pick the newest doc file matching today's date prefix.
442
+
443
+ Returns the file with the largest mtime whose name starts with
444
+ `YYYY-MM-DD-` for today's date. `None` if no such file exists.
445
+ """
446
+ if not doc_dir.is_dir():
447
+ return None
448
+ today = datetime.now().strftime("%Y-%m-%d")
449
+ prefix = f"{today}-"
450
+ candidates: list[Path] = []
451
+ try:
452
+ for entry in doc_dir.iterdir():
453
+ if entry.is_file() and entry.name.startswith(prefix) and entry.suffix == ".md":
454
+ candidates.append(entry)
455
+ except Exception:
456
+ return None
457
+ if not candidates:
458
+ return None
459
+ try:
460
+ return max(candidates, key=lambda p: p.stat().st_mtime)
461
+ except Exception:
462
+ return None
463
+
464
+
465
+ def _append_to_doc(record: TurnEndRecord) -> None:
466
+ """Append the marker line to the most-recent same-day doc, if any."""
467
+ root = _find_project_root()
468
+ if root is None:
469
+ return
470
+ doc_dir = root / "doc"
471
+ target = _pick_today_doc(doc_dir)
472
+ if target is None:
473
+ return
474
+ line = render_marker_line(record)
475
+ try:
476
+ # Ensure the marker starts on its own line even if the file did not
477
+ # end with a newline. Read the last byte cheaply rather than the
478
+ # whole file.
479
+ needs_leading_newline = False
480
+ try:
481
+ with target.open("rb") as fp:
482
+ fp.seek(0, os.SEEK_END)
483
+ if fp.tell() > 0:
484
+ fp.seek(-1, os.SEEK_END)
485
+ last = fp.read(1)
486
+ needs_leading_newline = last not in (b"\n", b"\r")
487
+ except Exception:
488
+ needs_leading_newline = False
489
+ with target.open("a", encoding="utf-8") as fp:
490
+ if needs_leading_newline:
491
+ fp.write("\n")
492
+ fp.write(line + "\n")
493
+ except Exception:
494
+ logger.debug("turn_end_summary doc write failed for %s", target, exc_info=True)
495
+
496
+
497
+ # --------------------------------------------------------------------------- #
498
+ # Test helpers
499
+ # --------------------------------------------------------------------------- #
500
+
501
+
502
+ def _reset_for_tests() -> None:
503
+ """Reset all module state. Test-only; not part of the public API."""
504
+ global _current
505
+ with _state_lock:
506
+ _current = _TurnState()
507
+
508
+
509
+ def _snapshot_for_tests() -> _TurnState:
510
+ """Return a shallow copy of the current in-progress turn state."""
511
+ with _state_lock:
512
+ return _TurnState(
513
+ start_monotonic=_current.start_monotonic,
514
+ thread_id=_current.thread_id,
515
+ turn_id=_current.turn_id,
516
+ turn_number=_current.turn_number,
517
+ reason=_current.reason,
518
+ detail=_current.detail,
519
+ finish_reason_raw=_current.finish_reason_raw,
520
+ finalized=_current.finalized,
521
+ started=_current.started,
522
+ )