deepy-cli 0.2.3__tar.gz → 0.2.5__tar.gz
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.
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/PKG-INFO +1 -1
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/pyproject.toml +1 -1
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/__init__.py +1 -1
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/AskUserQuestion.md +6 -0
- deepy_cli-0.2.5/src/deepy/data/tools/todo_write.md +16 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/compaction.py +14 -8
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/runner.py +159 -6
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/compact.py +10 -1
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/system.py +11 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/tool_docs.py +1 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/sessions/jsonl.py +112 -61
- deepy_cli-0.2.5/src/deepy/todos.py +111 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/agents.py +47 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/builtin.py +99 -22
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/message_view.py +111 -5
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/terminal.py +138 -88
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/README.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/__main__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/cli.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/config/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/config/settings.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/WebFetch.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/WebSearch.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/edit.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/modify.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/read.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/shell.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/data/tools/write.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/errors.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/agent.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/context.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/events.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/model_capabilities.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/provider.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/replay.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/llm/thinking.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/mcp.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/init_agents.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/rules.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/prompts/runtime_context.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/sessions/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/sessions/manager.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/skill_market.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/skills.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/status.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/file_state.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/result.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/shell_output.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/tools/shell_utils.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/types/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/types/sdk.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/types/tool_payloads.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/app.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/ask_user_question.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/exit_summary.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/file_mentions.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/loading_text.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/local_command.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/markdown.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/model_picker.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/prompt_buffer.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/prompt_input.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/session_list.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/session_picker.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/skill_picker.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/slash_commands.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/status_footer.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/styles.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/theme_picker.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/thinking_state.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/ui/welcome.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/update_check.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/usage.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/utils/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/utils/debug_logger.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/utils/error_logger.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/utils/json.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.5}/src/deepy/utils/notify.py +0 -0
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
标注 `(Recommended)` 或中文等价表达。不要为了低影响细节提问;可以合理假设时
|
|
11
11
|
继续推进并简短说明假设。
|
|
12
12
|
|
|
13
|
+
Agent Skills 可能使用通用说法,例如 ask the user、ask one question at a time、
|
|
14
|
+
wait for the user's response、get approval、review 或 confirm before continuing。
|
|
15
|
+
在 Deepy 中,除非 skill 明确要求不要使用工具,否则这些等待用户输入的步骤都应通过
|
|
16
|
+
`AskUserQuestion` 完成。开放问题也必须提供选项;可加入“自定义回答”/`Custom answer`
|
|
17
|
+
作为选项,让用户输入自由文本。
|
|
18
|
+
|
|
13
19
|
Args: `questions` (non-empty array). Each question needs `question` and non-empty `options`;
|
|
14
20
|
each option needs `label` and may include `description`. Use `multiSelect=true` only when
|
|
15
21
|
multiple choices are allowed.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
## todo_write
|
|
2
|
+
|
|
3
|
+
Maintain a session-scoped todo list for complex work.
|
|
4
|
+
|
|
5
|
+
Use `todo_write` when the user asks for several deliverables, the work touches
|
|
6
|
+
multiple files, or the task naturally breaks into three or more meaningful
|
|
7
|
+
steps. Do not use it for simple questions, tiny one-step edits, or routine
|
|
8
|
+
commands where a todo list would add noise.
|
|
9
|
+
|
|
10
|
+
Each update replaces the complete list. Keep item `id` values stable across
|
|
11
|
+
updates. Use exactly one `in_progress` item at a time, mark completed work as
|
|
12
|
+
`completed`, and update the list only when real progress changes. Omit `todos`
|
|
13
|
+
to read the current list; pass an empty list to clear it.
|
|
14
|
+
|
|
15
|
+
This tool only tracks progress. It does not delegate to subagents and it does
|
|
16
|
+
not request plan approval.
|
|
@@ -8,6 +8,7 @@ from typing import Any, Literal
|
|
|
8
8
|
from deepy.config import Settings
|
|
9
9
|
from deepy.prompts.compact import build_compact_prompt, build_compact_summary_message
|
|
10
10
|
from deepy.sessions.jsonl import DeepyJsonlSession
|
|
11
|
+
from deepy.todos import todo_state_prompt_text
|
|
11
12
|
from deepy.usage import TokenUsage, usage_from_run_result
|
|
12
13
|
|
|
13
14
|
from .context import estimate_tokens_for_item, estimate_tokens_for_items
|
|
@@ -81,6 +82,7 @@ async def compact_session(
|
|
|
81
82
|
settings,
|
|
82
83
|
provider=provider,
|
|
83
84
|
focus_instruction=focus_instruction,
|
|
85
|
+
todo_state=session.todo_state(),
|
|
84
86
|
)
|
|
85
87
|
replacement = sanitize_sdk_items_for_replay(
|
|
86
88
|
[build_compact_summary_message(summary), *to_preserve]
|
|
@@ -124,9 +126,7 @@ async def ensure_context_ready(
|
|
|
124
126
|
before_tokens = state.active_tokens + additional_tokens
|
|
125
127
|
latest_context_usage = session.latest_context_window_usage()
|
|
126
128
|
trigger_tokens = (
|
|
127
|
-
latest_context_usage.used_tokens
|
|
128
|
-
if latest_context_usage is not None
|
|
129
|
-
else before_tokens
|
|
129
|
+
latest_context_usage.used_tokens if latest_context_usage is not None else before_tokens
|
|
130
130
|
)
|
|
131
131
|
compacted: CompactionResult | None = None
|
|
132
132
|
if trigger_tokens >= settings.context.resolved_compact_threshold:
|
|
@@ -144,9 +144,7 @@ async def ensure_context_ready(
|
|
|
144
144
|
else:
|
|
145
145
|
after_context_usage = session.latest_context_window_usage()
|
|
146
146
|
fit_tokens = (
|
|
147
|
-
after_context_usage.used_tokens
|
|
148
|
-
if after_context_usage is not None
|
|
149
|
-
else after_tokens
|
|
147
|
+
after_context_usage.used_tokens if after_context_usage is not None else after_tokens
|
|
150
148
|
)
|
|
151
149
|
if fit_tokens + settings.context.reserved_context_tokens >= settings.context.window_tokens:
|
|
152
150
|
raise ContextCompactionError(
|
|
@@ -198,11 +196,16 @@ async def run_compaction_model(
|
|
|
198
196
|
*,
|
|
199
197
|
provider: ProviderBundle | None = None,
|
|
200
198
|
focus_instruction: str | None = None,
|
|
199
|
+
todo_state: list[dict[str, str]] | None = None,
|
|
201
200
|
) -> tuple[str, TokenUsage]:
|
|
202
201
|
from agents import Agent, RunConfig, Runner
|
|
203
202
|
|
|
204
203
|
resolved_provider = provider or build_provider_bundle(settings)
|
|
205
|
-
prompt = build_compact_prompt(
|
|
204
|
+
prompt = build_compact_prompt(
|
|
205
|
+
items,
|
|
206
|
+
focus_instruction=focus_instruction,
|
|
207
|
+
todo_context=todo_state_prompt_text(todo_state or []),
|
|
208
|
+
)
|
|
206
209
|
agent = Agent(
|
|
207
210
|
name="Deepy Context Compactor",
|
|
208
211
|
instructions="Create a compact continuation summary. Do not call tools.",
|
|
@@ -254,7 +257,10 @@ def _expand_preserve_start_for_tool_group(items: list[dict[str, Any]], preserve_
|
|
|
254
257
|
if not needed_call_ids:
|
|
255
258
|
return preserve_start
|
|
256
259
|
for index in range(preserve_start - 1, -1, -1):
|
|
257
|
-
if
|
|
260
|
+
if (
|
|
261
|
+
items[index].get("type") == "function_call"
|
|
262
|
+
and _call_id(items[index]) in needed_call_ids
|
|
263
|
+
):
|
|
258
264
|
preserve_start = index
|
|
259
265
|
needed_call_ids.discard(_call_id(items[index]))
|
|
260
266
|
if not needed_call_ids:
|
|
@@ -12,6 +12,7 @@ from deepy.config import Settings, load_settings
|
|
|
12
12
|
from deepy.sessions.jsonl import DeepyJsonlSession
|
|
13
13
|
from deepy.skills import find_skill
|
|
14
14
|
from deepy.mcp import DeepyMcpRuntime
|
|
15
|
+
from deepy.todos import normalize_todo_items
|
|
15
16
|
from deepy.tools import ToolRuntime
|
|
16
17
|
from deepy.usage import TokenUsage, merge_usage, normalize_usage, usage_from_run_result
|
|
17
18
|
from deepy.utils import json as json_utils
|
|
@@ -24,6 +25,11 @@ from .events import DeepyStreamEvent, normalize_stream_event
|
|
|
24
25
|
from .provider import ProviderBundle, build_provider_bundle
|
|
25
26
|
|
|
26
27
|
DEFAULT_MAX_TURNS = 100
|
|
28
|
+
INTERRUPTED_MARKER_TEXT = (
|
|
29
|
+
"Interrupted by user with Esc. This turn was stopped before completion. "
|
|
30
|
+
"Do not continue the interrupted request unless the user explicitly asks to continue."
|
|
31
|
+
)
|
|
32
|
+
INTERRUPTED_TOOL_OUTPUT_TEXT = "Tool execution interrupted by user with Esc."
|
|
27
33
|
|
|
28
34
|
|
|
29
35
|
@dataclass(frozen=True)
|
|
@@ -60,7 +66,15 @@ async def run_prompt_once(
|
|
|
60
66
|
root = (project_root or Path.cwd()).resolve()
|
|
61
67
|
resolved_settings = settings or load_settings()
|
|
62
68
|
resolved_provider = provider or build_provider_bundle(resolved_settings)
|
|
63
|
-
|
|
69
|
+
session = (
|
|
70
|
+
DeepyJsonlSession.open(root, session_id) if session_id else DeepyJsonlSession.create(root)
|
|
71
|
+
)
|
|
72
|
+
initial_todos, _ = normalize_todo_items(session.todo_state())
|
|
73
|
+
runtime = ToolRuntime(
|
|
74
|
+
cwd=root,
|
|
75
|
+
settings=resolved_settings,
|
|
76
|
+
todo_items=initial_todos or [],
|
|
77
|
+
)
|
|
64
78
|
created_mcp_runtime: DeepyMcpRuntime | None = None
|
|
65
79
|
if mcp_runtime is None:
|
|
66
80
|
created_mcp_runtime = DeepyMcpRuntime(resolved_settings, project_root=root)
|
|
@@ -76,11 +90,6 @@ async def run_prompt_once(
|
|
|
76
90
|
mcp_servers=mcp_runtime.active_servers,
|
|
77
91
|
preferred_mcp_web_search_tools=mcp_runtime.preferred_web_search_tools,
|
|
78
92
|
)
|
|
79
|
-
session = (
|
|
80
|
-
DeepyJsonlSession.open(root, session_id)
|
|
81
|
-
if session_id
|
|
82
|
-
else DeepyJsonlSession.create(root)
|
|
83
|
-
)
|
|
84
93
|
started_at = time.time()
|
|
85
94
|
try:
|
|
86
95
|
readiness = await ensure_context_ready(
|
|
@@ -124,6 +133,7 @@ async def run_prompt_once(
|
|
|
124
133
|
pending_questions: list[dict[str, Any]] = []
|
|
125
134
|
usage = TokenUsage()
|
|
126
135
|
interrupt_task: asyncio.Task[bool] | None = None
|
|
136
|
+
session_baseline_count = len(await session.get_items())
|
|
127
137
|
try:
|
|
128
138
|
result = Runner.run_streamed(
|
|
129
139
|
agent,
|
|
@@ -268,6 +278,12 @@ async def run_prompt_once(
|
|
|
268
278
|
raise
|
|
269
279
|
|
|
270
280
|
interrupted = interrupted or await _finish_interrupt_task(interrupt_task)
|
|
281
|
+
if interrupted:
|
|
282
|
+
await _reconcile_interrupted_session_tail(
|
|
283
|
+
session,
|
|
284
|
+
baseline_count=session_baseline_count,
|
|
285
|
+
prompt=prompt,
|
|
286
|
+
)
|
|
271
287
|
|
|
272
288
|
final_output = getattr(result, "final_output", None)
|
|
273
289
|
output = final_output if isinstance(final_output, str) else "".join(chunks)
|
|
@@ -508,6 +524,143 @@ async def _finish_interrupt_task(task: asyncio.Task[bool] | None) -> bool:
|
|
|
508
524
|
return False
|
|
509
525
|
|
|
510
526
|
|
|
527
|
+
async def _reconcile_interrupted_session_tail(
|
|
528
|
+
session: DeepyJsonlSession,
|
|
529
|
+
*,
|
|
530
|
+
baseline_count: int,
|
|
531
|
+
prompt: str,
|
|
532
|
+
) -> None:
|
|
533
|
+
items = await session.get_items()
|
|
534
|
+
if baseline_count < 0 or baseline_count > len(items):
|
|
535
|
+
return
|
|
536
|
+
suffix = items[baseline_count:]
|
|
537
|
+
if not suffix:
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
if len(suffix) == 1 and _is_user_prompt_item(suffix[0], prompt):
|
|
541
|
+
await session.pop_item()
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
additions = _interrupted_tool_output_items(suffix)
|
|
545
|
+
if not _is_interrupt_marker_item(suffix[-1]):
|
|
546
|
+
additions.append(_interrupted_marker_item())
|
|
547
|
+
if additions:
|
|
548
|
+
await session.add_items(additions)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _is_user_prompt_item(item: dict[str, Any], prompt: str) -> bool:
|
|
552
|
+
if item.get("role") != "user":
|
|
553
|
+
return False
|
|
554
|
+
return _item_text_content(item) == prompt
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _item_text_content(item: dict[str, Any]) -> str:
|
|
558
|
+
content = item.get("content")
|
|
559
|
+
if isinstance(content, str):
|
|
560
|
+
return content
|
|
561
|
+
if not isinstance(content, list):
|
|
562
|
+
return ""
|
|
563
|
+
parts: list[str] = []
|
|
564
|
+
for part in content:
|
|
565
|
+
if isinstance(part, dict):
|
|
566
|
+
text = part.get("text")
|
|
567
|
+
if text is None:
|
|
568
|
+
text = part.get("input_text")
|
|
569
|
+
if isinstance(text, str):
|
|
570
|
+
parts.append(text)
|
|
571
|
+
return "".join(parts)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _interrupted_tool_output_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
575
|
+
output_call_ids = {
|
|
576
|
+
call_id
|
|
577
|
+
for item in items
|
|
578
|
+
if (call_id := _function_call_output_id(item))
|
|
579
|
+
}
|
|
580
|
+
additions: list[dict[str, Any]] = []
|
|
581
|
+
added_call_ids: set[str] = set()
|
|
582
|
+
for item in items:
|
|
583
|
+
for call_id, output_item in _missing_output_items_for_call(item, output_call_ids):
|
|
584
|
+
if call_id in added_call_ids:
|
|
585
|
+
continue
|
|
586
|
+
additions.append(output_item)
|
|
587
|
+
added_call_ids.add(call_id)
|
|
588
|
+
return additions
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _missing_output_items_for_call(
|
|
592
|
+
item: dict[str, Any],
|
|
593
|
+
output_call_ids: set[str],
|
|
594
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
595
|
+
call_id = _function_call_id(item)
|
|
596
|
+
if call_id:
|
|
597
|
+
return (
|
|
598
|
+
[]
|
|
599
|
+
if call_id in output_call_ids
|
|
600
|
+
else [
|
|
601
|
+
(
|
|
602
|
+
call_id,
|
|
603
|
+
{
|
|
604
|
+
"type": "function_call_output",
|
|
605
|
+
"call_id": call_id,
|
|
606
|
+
"output": INTERRUPTED_TOOL_OUTPUT_TEXT,
|
|
607
|
+
},
|
|
608
|
+
)
|
|
609
|
+
]
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
missing: list[tuple[str, dict[str, Any]]] = []
|
|
613
|
+
if item.get("role") != "assistant":
|
|
614
|
+
return missing
|
|
615
|
+
tool_calls = item.get("tool_calls")
|
|
616
|
+
if not isinstance(tool_calls, list):
|
|
617
|
+
return missing
|
|
618
|
+
for tool_call in tool_calls:
|
|
619
|
+
if not isinstance(tool_call, dict):
|
|
620
|
+
continue
|
|
621
|
+
chat_call_id = tool_call.get("id")
|
|
622
|
+
if not isinstance(chat_call_id, str) or not chat_call_id or chat_call_id in output_call_ids:
|
|
623
|
+
continue
|
|
624
|
+
missing.append(
|
|
625
|
+
(
|
|
626
|
+
chat_call_id,
|
|
627
|
+
{
|
|
628
|
+
"role": "tool",
|
|
629
|
+
"tool_call_id": chat_call_id,
|
|
630
|
+
"content": INTERRUPTED_TOOL_OUTPUT_TEXT,
|
|
631
|
+
},
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
return missing
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _function_call_id(item: dict[str, Any]) -> str:
|
|
638
|
+
if item.get("type") != "function_call":
|
|
639
|
+
return ""
|
|
640
|
+
call_id = item.get("call_id")
|
|
641
|
+
if call_id is None:
|
|
642
|
+
call_id = item.get("id")
|
|
643
|
+
return call_id if isinstance(call_id, str) else ""
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _function_call_output_id(item: dict[str, Any]) -> str:
|
|
647
|
+
if item.get("type") == "function_call_output":
|
|
648
|
+
call_id = item.get("call_id")
|
|
649
|
+
return call_id if isinstance(call_id, str) else ""
|
|
650
|
+
if item.get("role") == "tool":
|
|
651
|
+
tool_call_id = item.get("tool_call_id")
|
|
652
|
+
return tool_call_id if isinstance(tool_call_id, str) else ""
|
|
653
|
+
return ""
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _interrupted_marker_item() -> dict[str, Any]:
|
|
657
|
+
return {"role": "assistant", "content": INTERRUPTED_MARKER_TEXT}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _is_interrupt_marker_item(item: dict[str, Any]) -> bool:
|
|
661
|
+
return item.get("role") == "assistant" and item.get("content") == INTERRUPTED_MARKER_TEXT
|
|
662
|
+
|
|
663
|
+
|
|
511
664
|
def _pending_questions_from_tool_output(output: str) -> list[dict[str, Any]]:
|
|
512
665
|
if not output.strip():
|
|
513
666
|
return []
|
|
@@ -94,6 +94,7 @@ def build_compact_prompt(
|
|
|
94
94
|
session_messages: list[dict[str, Any]],
|
|
95
95
|
*,
|
|
96
96
|
focus_instruction: str | None = None,
|
|
97
|
+
todo_context: str | None = None,
|
|
97
98
|
) -> str:
|
|
98
99
|
jsonl = "\n".join(_compact_message_json(message) for message in session_messages)
|
|
99
100
|
focus = ""
|
|
@@ -104,7 +105,15 @@ def build_compact_prompt(
|
|
|
104
105
|
"dropping current task state:\n"
|
|
105
106
|
f"{focus_instruction.strip()}"
|
|
106
107
|
)
|
|
107
|
-
|
|
108
|
+
todo = ""
|
|
109
|
+
if todo_context and todo_context.strip():
|
|
110
|
+
todo = (
|
|
111
|
+
"\n\nCurrent todo state:\n"
|
|
112
|
+
"Preserve this active task plan in the summary so the next model turn can continue "
|
|
113
|
+
"or reconcile progress:\n"
|
|
114
|
+
f"{todo_context.strip()}"
|
|
115
|
+
)
|
|
116
|
+
return f"{COMPACT_PROMPT_BASE}{focus}{todo}\n\nconversation below:\n\n```jsonl\n{jsonl}\n```"
|
|
108
117
|
|
|
109
118
|
|
|
110
119
|
def build_compact_summary_message(summary: str) -> dict[str, str]:
|
|
@@ -53,6 +53,13 @@ Core rules:
|
|
|
53
53
|
- Ask when clarification would materially improve the result: ambiguous intent, unclear scope,
|
|
54
54
|
user preferences, high-impact trade-offs, or required approval. For low-impact details,
|
|
55
55
|
proceed with a reasonable assumption and state it briefly.
|
|
56
|
+
- Use `todo_write` for complex multi-step work, multi-file changes, or several
|
|
57
|
+
distinct deliverables. Keep one item `in_progress`, update the complete list
|
|
58
|
+
only when real task state changes, and reconcile completed work before the
|
|
59
|
+
final answer. Skip `todo_write` for simple questions and obvious one-step
|
|
60
|
+
tasks so progress tracking does not create noise.
|
|
61
|
+
- `todo_write` is only for local task tracking. Do not treat it as subagent
|
|
62
|
+
delegation, a `task` tool, or a plan approval mode.
|
|
56
63
|
|
|
57
64
|
Tool protocol:
|
|
58
65
|
Tool results are JSON strings: ok, name, output, error, metadata, awaitUserResponse.
|
|
@@ -63,6 +70,10 @@ Skill protocol:
|
|
|
63
70
|
- If the user's task matches an available skill, call `load_skill` with the exact skill name before relying on that skill's detailed instructions, scripts, references, or assets.
|
|
64
71
|
- If the user explicitly invoked a skill, follow the loaded skill instructions and use the user's request inside that context.
|
|
65
72
|
- Skill files are standard Agent Skills. Resolve relative scripts, references, and assets from the skill root returned by `load_skill`.
|
|
73
|
+
- If a loaded skill says to ask the user, ask one question at a time, wait for
|
|
74
|
+
the user's response, get approval, or have the user review or confirm before
|
|
75
|
+
continuing, satisfy that wait point with `AskUserQuestion` unless the skill
|
|
76
|
+
explicitly says to ask in the final answer without tools.
|
|
66
77
|
|
|
67
78
|
Tool documentation:
|
|
68
79
|
{tool_docs_block}
|
|
@@ -8,13 +8,21 @@ from typing import Any
|
|
|
8
8
|
|
|
9
9
|
from deepy.llm.context import estimate_tokens_for_item
|
|
10
10
|
from deepy.llm.replay import sanitize_sdk_items_for_replay
|
|
11
|
-
from deepy.
|
|
11
|
+
from deepy.todos import normalize_persisted_todo_state, todo_state_from_tool_output
|
|
12
|
+
from deepy.usage import (
|
|
13
|
+
ContextWindowUsage,
|
|
14
|
+
TokenUsage,
|
|
15
|
+
context_window_usage,
|
|
16
|
+
merge_usage,
|
|
17
|
+
normalize_usage,
|
|
18
|
+
)
|
|
12
19
|
from deepy.utils import json as json_utils
|
|
13
20
|
|
|
14
21
|
SESSION_INDEX_VERSION = 2
|
|
15
22
|
MAX_SESSION_INDEX_ENTRIES = 50
|
|
16
23
|
CONTEXT_UNDERCOUNT_REPAIR_RATIO = 2
|
|
17
24
|
CONTEXT_UNDERCOUNT_REPAIR_MIN_DELTA = 128
|
|
25
|
+
_UNSET = object()
|
|
18
26
|
|
|
19
27
|
|
|
20
28
|
@dataclass(frozen=True)
|
|
@@ -30,6 +38,7 @@ class SessionEntry:
|
|
|
30
38
|
last_usage_tokens: int | None = None
|
|
31
39
|
pending_tokens: int = 0
|
|
32
40
|
last_usage_record_count: int | None = None
|
|
41
|
+
todo_state: list[dict[str, str]] | None = None
|
|
33
42
|
|
|
34
43
|
|
|
35
44
|
def project_code(project_root: Path) -> str:
|
|
@@ -123,7 +132,9 @@ class DeepyJsonlSession:
|
|
|
123
132
|
with self.path.open("w", encoding="utf-8") as fh:
|
|
124
133
|
for record in records:
|
|
125
134
|
fh.write(json_utils.dumps(record) + "\n")
|
|
126
|
-
self._loaded_items = [
|
|
135
|
+
self._loaded_items = [
|
|
136
|
+
item for item in (_sdk_item_from_record(record) for record in records) if item
|
|
137
|
+
]
|
|
127
138
|
state = self.context_token_state(records)
|
|
128
139
|
self._touch_index(
|
|
129
140
|
active_tokens=state.active_tokens,
|
|
@@ -144,6 +155,7 @@ class DeepyJsonlSession:
|
|
|
144
155
|
last_usage_tokens=0,
|
|
145
156
|
pending_tokens=0,
|
|
146
157
|
last_usage_record_count=0,
|
|
158
|
+
todo_state=[],
|
|
147
159
|
)
|
|
148
160
|
|
|
149
161
|
def record_usage(self, usage: TokenUsage | dict[str, Any] | None) -> None:
|
|
@@ -186,7 +198,9 @@ class DeepyJsonlSession:
|
|
|
186
198
|
last_usage_record_count=None,
|
|
187
199
|
estimated=True,
|
|
188
200
|
)
|
|
189
|
-
pending_tokens = sum(
|
|
201
|
+
pending_tokens = sum(
|
|
202
|
+
_estimate_record_tokens(record) for record in source[last_usage_record_count:]
|
|
203
|
+
)
|
|
190
204
|
checkpoint_tokens = last_usage_tokens + pending_tokens
|
|
191
205
|
estimated_tokens = self._estimate_active_tokens(source)
|
|
192
206
|
active_tokens = _repair_undercounted_context_tokens(
|
|
@@ -211,7 +225,9 @@ class DeepyJsonlSession:
|
|
|
211
225
|
|
|
212
226
|
def latest_context_window_usage(self) -> ContextWindowUsage | None:
|
|
213
227
|
previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
|
|
214
|
-
latest_tokens =
|
|
228
|
+
latest_tokens = (
|
|
229
|
+
_optional_int(previous.get("latestContextWindowTokens")) if previous else None
|
|
230
|
+
)
|
|
215
231
|
if latest_tokens is not None:
|
|
216
232
|
return ContextWindowUsage(
|
|
217
233
|
used_tokens=latest_tokens,
|
|
@@ -223,6 +239,13 @@ class DeepyJsonlSession:
|
|
|
223
239
|
return context_window_usage(usage)
|
|
224
240
|
return None
|
|
225
241
|
|
|
242
|
+
def todo_state(self) -> list[dict[str, str]]:
|
|
243
|
+
previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
|
|
244
|
+
if not previous:
|
|
245
|
+
return []
|
|
246
|
+
todo_state = normalize_persisted_todo_state(previous.get("todoState"))
|
|
247
|
+
return todo_state or []
|
|
248
|
+
|
|
226
249
|
async def replace_items(
|
|
227
250
|
self,
|
|
228
251
|
items: list[dict[str, Any]],
|
|
@@ -308,6 +331,7 @@ class DeepyJsonlSession:
|
|
|
308
331
|
last_usage_tokens: int | None = None,
|
|
309
332
|
pending_tokens: int | None = None,
|
|
310
333
|
last_usage_record_count: int | None = None,
|
|
334
|
+
todo_state: object = _UNSET,
|
|
311
335
|
) -> None:
|
|
312
336
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
313
337
|
index_path = self.path.parent / "sessions-index.json"
|
|
@@ -316,61 +340,64 @@ class DeepyJsonlSession:
|
|
|
316
340
|
sessions = _index_sessions(raw)
|
|
317
341
|
previous = next((entry for entry in sessions if entry.get("id") == self.session_id), {})
|
|
318
342
|
sessions = [entry for entry in sessions if entry.get("id") != self.session_id]
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
{"latestContextWindowTokens": previous["latestContextWindowTokens"]}
|
|
332
|
-
if "latestContextWindowTokens" in previous
|
|
333
|
-
else {}
|
|
334
|
-
)
|
|
335
|
-
),
|
|
336
|
-
**(
|
|
337
|
-
{"lastUsageTokens": last_usage_tokens}
|
|
338
|
-
if last_usage_tokens is not None
|
|
339
|
-
else (
|
|
340
|
-
{"lastUsageTokens": previous["lastUsageTokens"]}
|
|
341
|
-
if "lastUsageTokens" in previous
|
|
342
|
-
else {}
|
|
343
|
-
)
|
|
344
|
-
),
|
|
345
|
-
**(
|
|
346
|
-
{"pendingTokens": pending_tokens}
|
|
347
|
-
if pending_tokens is not None
|
|
348
|
-
else (
|
|
349
|
-
{"pendingTokens": previous["pendingTokens"]}
|
|
350
|
-
if "pendingTokens" in previous
|
|
351
|
-
else {}
|
|
352
|
-
)
|
|
353
|
-
),
|
|
354
|
-
**(
|
|
355
|
-
{"lastUsageRecordCount": last_usage_record_count}
|
|
356
|
-
if last_usage_record_count is not None
|
|
357
|
-
else (
|
|
358
|
-
{"lastUsageRecordCount": previous["lastUsageRecordCount"]}
|
|
359
|
-
if "lastUsageRecordCount" in previous
|
|
360
|
-
else {}
|
|
361
|
-
)
|
|
362
|
-
),
|
|
363
|
-
"createdAt": _coerce_int(previous.get("createdAt"), now),
|
|
364
|
-
"updatedAt": now,
|
|
365
|
-
**({"usage": usage} if usage is not None else {}),
|
|
366
|
-
**(
|
|
367
|
-
{"usage": previous["usage"]}
|
|
368
|
-
if usage is None and isinstance(previous.get("usage"), dict)
|
|
343
|
+
entry = {
|
|
344
|
+
"id": self.session_id,
|
|
345
|
+
"path": self.path.name,
|
|
346
|
+
"activeTokens": active_tokens
|
|
347
|
+
if active_tokens is not None
|
|
348
|
+
else _coerce_int(previous.get("activeTokens"), 0),
|
|
349
|
+
**(
|
|
350
|
+
{"latestContextWindowTokens": latest_context_window_tokens}
|
|
351
|
+
if latest_context_window_tokens is not None
|
|
352
|
+
else (
|
|
353
|
+
{"latestContextWindowTokens": previous["latestContextWindowTokens"]}
|
|
354
|
+
if "latestContextWindowTokens" in previous
|
|
369
355
|
else {}
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
356
|
+
)
|
|
357
|
+
),
|
|
358
|
+
**(
|
|
359
|
+
{"lastUsageTokens": last_usage_tokens}
|
|
360
|
+
if last_usage_tokens is not None
|
|
361
|
+
else (
|
|
362
|
+
{"lastUsageTokens": previous["lastUsageTokens"]}
|
|
363
|
+
if "lastUsageTokens" in previous
|
|
364
|
+
else {}
|
|
365
|
+
)
|
|
366
|
+
),
|
|
367
|
+
**(
|
|
368
|
+
{"pendingTokens": pending_tokens}
|
|
369
|
+
if pending_tokens is not None
|
|
370
|
+
else (
|
|
371
|
+
{"pendingTokens": previous["pendingTokens"]}
|
|
372
|
+
if "pendingTokens" in previous
|
|
373
|
+
else {}
|
|
374
|
+
)
|
|
375
|
+
),
|
|
376
|
+
**(
|
|
377
|
+
{"lastUsageRecordCount": last_usage_record_count}
|
|
378
|
+
if last_usage_record_count is not None
|
|
379
|
+
else (
|
|
380
|
+
{"lastUsageRecordCount": previous["lastUsageRecordCount"]}
|
|
381
|
+
if "lastUsageRecordCount" in previous
|
|
382
|
+
else {}
|
|
383
|
+
)
|
|
384
|
+
),
|
|
385
|
+
"createdAt": _coerce_int(previous.get("createdAt"), now),
|
|
386
|
+
"updatedAt": now,
|
|
387
|
+
**({"usage": usage} if usage is not None else {}),
|
|
388
|
+
**(
|
|
389
|
+
{"usage": previous["usage"]}
|
|
390
|
+
if usage is None and isinstance(previous.get("usage"), dict)
|
|
391
|
+
else {}
|
|
392
|
+
),
|
|
393
|
+
**({"processes": previous["processes"]} if "processes" in previous else {}),
|
|
394
|
+
}
|
|
395
|
+
if todo_state is _UNSET:
|
|
396
|
+
if "todoState" in previous:
|
|
397
|
+
entry["todoState"] = previous["todoState"]
|
|
398
|
+
elif todo_state is not None:
|
|
399
|
+
entry["todoState"] = todo_state
|
|
400
|
+
sessions.insert(0, entry)
|
|
374
401
|
payload = {
|
|
375
402
|
"version": SESSION_INDEX_VERSION,
|
|
376
403
|
"sessions": sessions[:MAX_SESSION_INDEX_ENTRIES],
|
|
@@ -379,10 +406,13 @@ class DeepyJsonlSession:
|
|
|
379
406
|
|
|
380
407
|
def _touch_index_after_append(self, appended_items: list[dict[str, Any]]) -> None:
|
|
381
408
|
previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
|
|
409
|
+
todo_state = _latest_todo_state_from_items(appended_items)
|
|
382
410
|
if previous and _optional_int(previous.get("lastUsageTokens")) is not None:
|
|
383
411
|
last_usage_tokens = _optional_int(previous.get("lastUsageTokens")) or 0
|
|
384
412
|
previous_pending = _coerce_int(previous.get("pendingTokens"), 0)
|
|
385
|
-
pending_tokens = previous_pending + sum(
|
|
413
|
+
pending_tokens = previous_pending + sum(
|
|
414
|
+
estimate_tokens_for_item(item) for item in appended_items
|
|
415
|
+
)
|
|
386
416
|
self._touch_index(
|
|
387
417
|
active_tokens=last_usage_tokens + pending_tokens,
|
|
388
418
|
last_usage_tokens=last_usage_tokens,
|
|
@@ -391,9 +421,13 @@ class DeepyJsonlSession:
|
|
|
391
421
|
previous.get("lastUsageRecordCount"),
|
|
392
422
|
max(0, len(self._load_records()) - len(appended_items)),
|
|
393
423
|
),
|
|
424
|
+
todo_state=todo_state if todo_state is not None else _UNSET,
|
|
394
425
|
)
|
|
395
426
|
return
|
|
396
|
-
self._touch_index(
|
|
427
|
+
self._touch_index(
|
|
428
|
+
active_tokens=self._estimate_active_tokens(),
|
|
429
|
+
todo_state=todo_state if todo_state is not None else _UNSET,
|
|
430
|
+
)
|
|
397
431
|
|
|
398
432
|
|
|
399
433
|
@dataclass(frozen=True)
|
|
@@ -436,6 +470,7 @@ def list_session_entries(project_root: Path, deepy_home: Path | None = None) ->
|
|
|
436
470
|
last_usage_tokens=_optional_int(item.get("lastUsageTokens")),
|
|
437
471
|
pending_tokens=_coerce_int(item.get("pendingTokens"), 0),
|
|
438
472
|
last_usage_record_count=_optional_int(item.get("lastUsageRecordCount")),
|
|
473
|
+
todo_state=normalize_persisted_todo_state(item.get("todoState")),
|
|
439
474
|
)
|
|
440
475
|
)
|
|
441
476
|
return entries
|
|
@@ -460,7 +495,11 @@ def _index_sessions(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
460
495
|
|
|
461
496
|
def _entry_for_session(index_path: Path, session_id: str) -> dict[str, Any] | None:
|
|
462
497
|
return next(
|
|
463
|
-
(
|
|
498
|
+
(
|
|
499
|
+
entry
|
|
500
|
+
for entry in _index_sessions(_read_index(index_path))
|
|
501
|
+
if entry.get("id") == session_id
|
|
502
|
+
),
|
|
464
503
|
None,
|
|
465
504
|
)
|
|
466
505
|
|
|
@@ -473,6 +512,18 @@ def _sdk_item_from_record(record: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
473
512
|
return dict(item) if isinstance(item, dict) else None
|
|
474
513
|
|
|
475
514
|
|
|
515
|
+
def _latest_todo_state_from_items(items: list[dict[str, Any]]) -> list[dict[str, str]] | None:
|
|
516
|
+
latest: list[dict[str, str]] | None = None
|
|
517
|
+
for item in items:
|
|
518
|
+
output = item.get("output")
|
|
519
|
+
if output is None and item.get("role") == "tool":
|
|
520
|
+
output = item.get("content")
|
|
521
|
+
todo_state = todo_state_from_tool_output(output)
|
|
522
|
+
if todo_state is not None:
|
|
523
|
+
latest = todo_state
|
|
524
|
+
return latest
|
|
525
|
+
|
|
526
|
+
|
|
476
527
|
def _coerce_int(value: Any, default: int) -> int:
|
|
477
528
|
if isinstance(value, bool):
|
|
478
529
|
return default
|