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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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
|
+
)
|