deepy-cli 0.2.3__tar.gz → 0.2.4__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.4}/PKG-INFO +1 -1
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/pyproject.toml +1 -1
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/__init__.py +1 -1
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/AskUserQuestion.md +6 -0
- deepy_cli-0.2.4/src/deepy/data/tools/todo_write.md +16 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/compaction.py +14 -8
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/runner.py +10 -6
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/compact.py +10 -1
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/system.py +11 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/tool_docs.py +1 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/sessions/jsonl.py +112 -61
- deepy_cli-0.2.4/src/deepy/todos.py +111 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/agents.py +47 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/builtin.py +99 -22
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/message_view.py +111 -5
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/terminal.py +138 -88
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/README.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/__main__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/cli.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/config/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/config/settings.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/WebFetch.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/WebSearch.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/edit.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/modify.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/read.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/shell.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/write.md +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/errors.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/agent.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/context.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/events.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/model_capabilities.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/provider.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/replay.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/thinking.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/mcp.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/init_agents.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/rules.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/runtime_context.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/sessions/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/sessions/manager.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/skill_market.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/skills.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/status.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/file_state.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/result.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/shell_output.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/shell_utils.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/types/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/types/sdk.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/types/tool_payloads.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/app.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/ask_user_question.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/exit_summary.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/file_mentions.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/loading_text.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/local_command.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/markdown.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/model_picker.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/prompt_buffer.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/prompt_input.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/session_list.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/session_picker.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/skill_picker.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/slash_commands.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/status_footer.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/styles.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/theme_picker.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/thinking_state.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/welcome.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/update_check.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/usage.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/utils/__init__.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/utils/debug_logger.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/utils/error_logger.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/utils/json.py +0 -0
- {deepy_cli-0.2.3 → deepy_cli-0.2.4}/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
|
|
@@ -60,7 +61,15 @@ async def run_prompt_once(
|
|
|
60
61
|
root = (project_root or Path.cwd()).resolve()
|
|
61
62
|
resolved_settings = settings or load_settings()
|
|
62
63
|
resolved_provider = provider or build_provider_bundle(resolved_settings)
|
|
63
|
-
|
|
64
|
+
session = (
|
|
65
|
+
DeepyJsonlSession.open(root, session_id) if session_id else DeepyJsonlSession.create(root)
|
|
66
|
+
)
|
|
67
|
+
initial_todos, _ = normalize_todo_items(session.todo_state())
|
|
68
|
+
runtime = ToolRuntime(
|
|
69
|
+
cwd=root,
|
|
70
|
+
settings=resolved_settings,
|
|
71
|
+
todo_items=initial_todos or [],
|
|
72
|
+
)
|
|
64
73
|
created_mcp_runtime: DeepyMcpRuntime | None = None
|
|
65
74
|
if mcp_runtime is None:
|
|
66
75
|
created_mcp_runtime = DeepyMcpRuntime(resolved_settings, project_root=root)
|
|
@@ -76,11 +85,6 @@ async def run_prompt_once(
|
|
|
76
85
|
mcp_servers=mcp_runtime.active_servers,
|
|
77
86
|
preferred_mcp_web_search_tools=mcp_runtime.preferred_web_search_tools,
|
|
78
87
|
)
|
|
79
|
-
session = (
|
|
80
|
-
DeepyJsonlSession.open(root, session_id)
|
|
81
|
-
if session_id
|
|
82
|
-
else DeepyJsonlSession.create(root)
|
|
83
|
-
)
|
|
84
88
|
started_at = time.time()
|
|
85
89
|
try:
|
|
86
90
|
readiness = await ensure_context_ready(
|
|
@@ -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
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from deepy.utils import json as json_utils
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TODO_STATUSES = {"pending", "in_progress", "completed"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TodoItem:
|
|
15
|
+
id: str
|
|
16
|
+
content: str
|
|
17
|
+
status: str
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict[str, str]:
|
|
20
|
+
return {"id": self.id, "content": self.content, "status": self.status}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_todo_items(value: object) -> tuple[list[TodoItem] | None, str | None]:
|
|
24
|
+
if not isinstance(value, list):
|
|
25
|
+
return None, "todos must be a list."
|
|
26
|
+
items: list[TodoItem] = []
|
|
27
|
+
seen_ids: set[str] = set()
|
|
28
|
+
in_progress_count = 0
|
|
29
|
+
for index, raw_item in enumerate(value):
|
|
30
|
+
if not isinstance(raw_item, Mapping):
|
|
31
|
+
return None, f"todo item {index + 1} must be an object."
|
|
32
|
+
item = cast(Mapping[str, object], raw_item)
|
|
33
|
+
todo_id = _clean_string(item.get("id"))
|
|
34
|
+
content = _clean_string(item.get("content"))
|
|
35
|
+
status = _clean_string(item.get("status"))
|
|
36
|
+
if not todo_id:
|
|
37
|
+
return None, f"todo item {index + 1} id must not be empty."
|
|
38
|
+
if todo_id in seen_ids:
|
|
39
|
+
return None, f"duplicate todo id: {todo_id}"
|
|
40
|
+
if not content:
|
|
41
|
+
return None, f"todo item {todo_id} content must not be empty."
|
|
42
|
+
if status not in TODO_STATUSES:
|
|
43
|
+
return None, f"todo item {todo_id} has unsupported status: {status or '(empty)'}"
|
|
44
|
+
if status == "in_progress":
|
|
45
|
+
in_progress_count += 1
|
|
46
|
+
seen_ids.add(todo_id)
|
|
47
|
+
items.append(TodoItem(id=todo_id, content=content, status=status))
|
|
48
|
+
if in_progress_count > 1:
|
|
49
|
+
return None, "only one todo item may be in_progress."
|
|
50
|
+
return items, None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def todo_items_to_payload(items: list[TodoItem]) -> list[dict[str, str]]:
|
|
54
|
+
return [item.to_dict() for item in items]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def todo_counts(items: list[TodoItem]) -> dict[str, int]:
|
|
58
|
+
return {
|
|
59
|
+
"total": len(items),
|
|
60
|
+
"pending": sum(1 for item in items if item.status == "pending"),
|
|
61
|
+
"in_progress": sum(1 for item in items if item.status == "in_progress"),
|
|
62
|
+
"completed": sum(1 for item in items if item.status == "completed"),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def todo_state_from_tool_output(output: object) -> list[dict[str, str]] | None:
|
|
67
|
+
payload = _tool_output_payload(output)
|
|
68
|
+
if not isinstance(payload, dict):
|
|
69
|
+
return None
|
|
70
|
+
metadata = payload.get("metadata")
|
|
71
|
+
if not isinstance(metadata, dict) or metadata.get("kind") != "todo_list":
|
|
72
|
+
return None
|
|
73
|
+
todos = metadata.get("todos")
|
|
74
|
+
items, error = normalize_todo_items(todos)
|
|
75
|
+
if error is not None or items is None:
|
|
76
|
+
return None
|
|
77
|
+
return todo_items_to_payload(items)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def normalize_persisted_todo_state(value: object) -> list[dict[str, str]] | None:
|
|
81
|
+
items, error = normalize_todo_items(value)
|
|
82
|
+
if error is not None or items is None:
|
|
83
|
+
return None
|
|
84
|
+
return todo_items_to_payload(items)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def todo_state_prompt_text(items: list[dict[str, str]]) -> str:
|
|
88
|
+
normalized, error = normalize_todo_items(items)
|
|
89
|
+
if error is not None or normalized is None or not normalized:
|
|
90
|
+
return ""
|
|
91
|
+
counts = todo_counts(normalized)
|
|
92
|
+
lines = [
|
|
93
|
+
"Active todo plan:",
|
|
94
|
+
f"- Progress: {counts['completed']}/{counts['total']} completed",
|
|
95
|
+
]
|
|
96
|
+
for item in normalized:
|
|
97
|
+
lines.append(f"- [{item.status}] {item.id}: {item.content}")
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _tool_output_payload(output: object) -> Any:
|
|
102
|
+
if isinstance(output, str):
|
|
103
|
+
try:
|
|
104
|
+
return json_utils.loads(output)
|
|
105
|
+
except json_utils.JSONDecodeError:
|
|
106
|
+
return None
|
|
107
|
+
return output
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _clean_string(value: object) -> str:
|
|
111
|
+
return value.strip() if isinstance(value, str) else ""
|
|
@@ -58,6 +58,10 @@ def build_function_tools(
|
|
|
58
58
|
args = _tool_args(raw_input)
|
|
59
59
|
return runtime.load_skill(_string_arg(args, "name"))
|
|
60
60
|
|
|
61
|
+
async def invoke_todo_write(_context: object, raw_input: str) -> str:
|
|
62
|
+
args = _tool_args(raw_input)
|
|
63
|
+
return runtime.todo_write(args.get("todos") if "todos" in args else None)
|
|
64
|
+
|
|
61
65
|
web_search_description = (
|
|
62
66
|
"Perform web searching using a natural language query. Use a small number of "
|
|
63
67
|
"targeted searches, then stop and synthesize once enough sources are available; "
|
|
@@ -139,6 +143,17 @@ def build_function_tools(
|
|
|
139
143
|
on_invoke_tool=invoke_load_skill,
|
|
140
144
|
strict_json_schema=False,
|
|
141
145
|
),
|
|
146
|
+
FunctionTool(
|
|
147
|
+
name="todo_write",
|
|
148
|
+
description=(
|
|
149
|
+
"Create, replace, read, or clear the session todo list for complex multi-step "
|
|
150
|
+
"work. Use it for meaningful task tracking, not simple questions or one-step edits. "
|
|
151
|
+
"Provide the complete todo list when updating; omit todos only to read current state."
|
|
152
|
+
),
|
|
153
|
+
params_json_schema=TODO_WRITE_SCHEMA,
|
|
154
|
+
on_invoke_tool=invoke_todo_write,
|
|
155
|
+
strict_json_schema=False,
|
|
156
|
+
),
|
|
142
157
|
]
|
|
143
158
|
|
|
144
159
|
|
|
@@ -395,3 +410,35 @@ LOAD_SKILL_SCHEMA: dict[str, Any] = {
|
|
|
395
410
|
"required": ["name"],
|
|
396
411
|
"additionalProperties": False,
|
|
397
412
|
}
|
|
413
|
+
TODO_WRITE_SCHEMA: dict[str, Any] = {
|
|
414
|
+
"type": "object",
|
|
415
|
+
"properties": {
|
|
416
|
+
"todos": {
|
|
417
|
+
"type": "array",
|
|
418
|
+
"description": (
|
|
419
|
+
"Complete replacement todo list. Omit this property to read the current todo list; "
|
|
420
|
+
"pass an empty list to clear it."
|
|
421
|
+
),
|
|
422
|
+
"items": {
|
|
423
|
+
"type": "object",
|
|
424
|
+
"properties": {
|
|
425
|
+
"id": {
|
|
426
|
+
"type": "string",
|
|
427
|
+
"description": "Stable id for this todo item.",
|
|
428
|
+
},
|
|
429
|
+
"content": {
|
|
430
|
+
"type": "string",
|
|
431
|
+
"description": "User-facing task text.",
|
|
432
|
+
},
|
|
433
|
+
"status": {
|
|
434
|
+
"type": "string",
|
|
435
|
+
"enum": ["pending", "in_progress", "completed"],
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
"required": ["id", "content", "status"],
|
|
439
|
+
"additionalProperties": False,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
"additionalProperties": False,
|
|
444
|
+
}
|