deepy-cli 0.2.16__tar.gz → 0.2.17__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.
Files changed (104) hide show
  1. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/PKG-INFO +1 -1
  2. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/pyproject.toml +1 -1
  3. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/__init__.py +1 -1
  4. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/apply_patch.md +4 -1
  5. deepy_cli-0.2.17/src/deepy/data/tools/read_file.md +19 -0
  6. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/write_file.md +3 -2
  7. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/system.py +1 -0
  8. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/agents.py +284 -35
  9. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/builtin.py +59 -10
  10. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/file_state.py +8 -0
  11. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/app.py +51 -6
  12. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/widgets.py +36 -2
  13. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/message_view.py +82 -0
  14. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/terminal.py +2 -1
  15. deepy_cli-0.2.16/src/deepy/data/tools/read_file.md +0 -16
  16. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/README.md +0 -0
  17. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/__main__.py +0 -0
  18. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/background_tasks.py +0 -0
  19. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/cli.py +0 -0
  20. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/config/__init__.py +0 -0
  21. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/config/settings.py +0 -0
  22. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/__init__.py +0 -0
  23. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
  24. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
  25. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  26. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/Search.md +0 -0
  27. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/WebFetch.md +0 -0
  28. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/WebSearch.md +0 -0
  29. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/__init__.py +0 -0
  30. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/edit_text.md +0 -0
  31. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/shell.md +0 -0
  32. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/task_list.md +0 -0
  33. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/task_output.md +0 -0
  34. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/task_stop.md +0 -0
  35. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/todo_write.md +0 -0
  36. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/errors.py +0 -0
  37. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/input_suggestions.py +0 -0
  38. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/__init__.py +0 -0
  39. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/agent.py +0 -0
  40. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/compaction.py +0 -0
  41. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/context.py +0 -0
  42. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/events.py +0 -0
  43. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/model_capabilities.py +0 -0
  44. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/provider.py +0 -0
  45. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/replay.py +0 -0
  46. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/runner.py +0 -0
  47. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/thinking.py +0 -0
  48. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/mcp.py +0 -0
  49. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/__init__.py +0 -0
  50. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/compact.py +0 -0
  51. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/init_agents.py +0 -0
  52. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/rules.py +0 -0
  53. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/runtime_context.py +0 -0
  54. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/tool_docs.py +0 -0
  55. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/session_cost.py +0 -0
  56. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/sessions/__init__.py +0 -0
  57. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/sessions/jsonl.py +0 -0
  58. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/sessions/manager.py +0 -0
  59. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/skill_market.py +0 -0
  60. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/skills.py +0 -0
  61. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/status.py +0 -0
  62. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/todos.py +0 -0
  63. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/__init__.py +0 -0
  64. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/result.py +0 -0
  65. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/search.py +0 -0
  66. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/shell_output.py +0 -0
  67. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/shell_utils.py +0 -0
  68. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/__init__.py +0 -0
  69. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/commands.py +0 -0
  70. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/compat.py +0 -0
  71. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/diff.py +0 -0
  72. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/runner.py +0 -0
  73. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/screens.py +0 -0
  74. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/state.py +0 -0
  75. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/types/__init__.py +0 -0
  76. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/types/sdk.py +0 -0
  77. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/types/tool_payloads.py +0 -0
  78. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/__init__.py +0 -0
  79. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/app.py +0 -0
  80. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/ask_user_question.py +0 -0
  81. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/exit_summary.py +0 -0
  82. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/file_mentions.py +0 -0
  83. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/loading_text.py +0 -0
  84. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/local_command.py +0 -0
  85. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/markdown.py +0 -0
  86. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/model_picker.py +0 -0
  87. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/prompt_buffer.py +0 -0
  88. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/prompt_input.py +0 -0
  89. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/session_list.py +0 -0
  90. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/session_picker.py +0 -0
  91. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/skill_picker.py +0 -0
  92. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/slash_commands.py +0 -0
  93. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/status_footer.py +0 -0
  94. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/styles.py +0 -0
  95. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/theme_picker.py +0 -0
  96. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/thinking_state.py +0 -0
  97. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/welcome.py +0 -0
  98. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/update_check.py +0 -0
  99. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/usage.py +0 -0
  100. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/utils/__init__.py +0 -0
  101. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/utils/debug_logger.py +0 -0
  102. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/utils/error_logger.py +0 -0
  103. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/utils/json.py +0 -0
  104. {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/utils/notify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.2.16
3
+ Version: 0.2.17
4
4
  Summary: Deepy - Vibe coding for DeepSeek models in your terminal
5
5
  Keywords: deepseek,coding-agent,terminal,cli,agents
6
6
  Author: kirineko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.2.16"
3
+ version = "0.2.17"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.16"
3
+ __version__ = "0.2.17"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -16,7 +16,8 @@ Supported operation types:
16
16
 
17
17
  - `create_file`: create a new text file with `file_path` and `content`.
18
18
  - `replace_file`: explicitly replace a whole existing file with `file_path`,
19
- `content`, `overwrite=true`, and either `snapshot_id` or `expected_hash`.
19
+ `content`, `overwrite=true`, and either `snapshot_token`, `snapshot_id`, or
20
+ `expected_hash`.
20
21
  - `delete_file`: delete `file_path`.
21
22
  - `move_file`: move `file_path` to `destination_path`.
22
23
  - `replace_block`: replace exact `old_text` with `new_text`.
@@ -41,6 +42,7 @@ Example:
41
42
  "replace_all": null,
42
43
  "overwrite": null,
43
44
  "snapshot_id": null,
45
+ "snapshot_token": null,
44
46
  "expected_hash": null
45
47
  },
46
48
  {
@@ -55,6 +57,7 @@ Example:
55
57
  "replace_all": null,
56
58
  "overwrite": null,
57
59
  "snapshot_id": null,
60
+ "snapshot_token": null,
58
61
  "expected_hash": null
59
62
  }
60
63
  ]
@@ -0,0 +1,19 @@
1
+ ## read_file
2
+
3
+ Read files or list directories before changes.
4
+
5
+ Args: `file_path`, optional `offset`, optional `limit`, optional `pages`.
6
+
7
+ Text output includes line numbers. Full text reads record a managed snapshot with
8
+ encoding, line-ending, `snapshot_id`, numeric `snapshot_token`, and content hash
9
+ metadata for later `edit_text`, `write_file`, or `apply_patch` calls. Prefer
10
+ `snapshot_token` for existing-file replacement when available because it avoids
11
+ identifier quoting mistakes; `snapshot_id` and content hash remain valid.
12
+ Partial reads return snippet metadata that can scope later `edit_text` calls but
13
+ do not authorize unrestricted whole-file replacement. For normal single-file
14
+ exact edits after a partial read, prefer `edit_text` with `file_path` and no
15
+ `snippet_id`; use the snippet only when you need to constrain the replacement to
16
+ that line range.
17
+
18
+ Non-text files such as images, notebooks, and PDFs may return descriptive
19
+ metadata, but they are not tracked for text mutation.
@@ -3,11 +3,12 @@
3
3
  Create a new text file or explicitly replace a whole file.
4
4
 
5
5
  Args: `file_path`, `content`, `overwrite`, optional `snapshot_id`, optional
6
- `expected_hash`.
6
+ `snapshot_token`, optional `expected_hash`.
7
7
 
8
8
  For new files, Deepy writes UTF-8 without BOM by default. For existing files,
9
9
  whole-file replacement requires `overwrite=true` and a fresh `snapshot_id` or
10
- `expected_hash` from `read_file`; this prevents accidental stale rewrites.
10
+ `snapshot_token` or `expected_hash` from `read_file`; this prevents accidental
11
+ stale rewrites. Prefer `snapshot_token` when available.
11
12
 
12
13
  Prefer `edit_text` for small targeted edits and `apply_patch` for structured or
13
14
  multi-file edits.
@@ -48,6 +48,7 @@ Core rules:
48
48
  - Use `Search` for local project code/text search instead of shell `grep`, `find`, or `rg`; narrow with `path`, `glob`, `output_mode`, `limit`, and `offset`.
49
49
  - Read existing files when you need context; exact `edit_text` edits can establish the managed snapshot internally.
50
50
  - Use `edit_text` for one small single-file exact edit. Use structured `apply_patch.operations` when a change has multiple edits in one file, touches multiple files, creates/deletes/moves files, or replaces a larger block. Use `write_file` for new files or explicit whole-file replacement.
51
+ - For existing-file replacement, pass `overwrite=true` plus the fresh `snapshot_token` from `read_file` when available; `snapshot_id` and content hash are also valid freshness tokens.
51
52
  - After project generators create scaffold files, read and edit the generated block instead of replacing the file.
52
53
  - Run shell commands using the Runtime context's command dialect and path style: `powershell` -> PowerShell with Windows paths; `cmd` -> cmd; `posix` -> POSIX shell.
53
54
  - Match visible thinking/reasoning language to the user's latest natural language. If the user asks in Chinese, you MUST write visible thinking/reasoning in Chinese unless they explicitly request another language. Do not switch visible thinking/reasoning to English for Chinese requests.
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  from copy import deepcopy
5
+ import re
5
6
  from typing import TYPE_CHECKING, Any
6
7
 
7
8
  from deepy.utils import json as json_utils
@@ -28,54 +29,69 @@ def build_function_tools(
28
29
  return tool
29
30
 
30
31
  async def invoke_shell(_context: object, raw_input: str) -> str:
31
- args, error = _tool_args(raw_input, "shell")
32
+ args, error, repair_metadata = _tool_args(raw_input, "shell", SHELL_SCHEMA)
32
33
  if error is not None:
33
34
  return error
34
- return await asyncio.to_thread(
35
+ result = await asyncio.to_thread(
35
36
  runtime.shell,
36
37
  _string_arg(args, "command"),
37
38
  timeout_ms=120_000,
38
39
  run_in_background=_bool_arg(args, "run_in_background", False),
39
40
  )
41
+ return _merge_tool_result_metadata(result, repair_metadata)
40
42
 
41
43
  async def invoke_task_list(_context: object, raw_input: str) -> str:
42
- args, error = _tool_args(raw_input, "task_list")
44
+ args, error, repair_metadata = _tool_args(raw_input, "task_list", TASK_LIST_SCHEMA)
43
45
  if error is not None:
44
46
  return error
45
- return runtime.task_list(
47
+ return _merge_tool_result_metadata(
48
+ runtime.task_list(
46
49
  active_only=_bool_arg(args, "active_only", False),
47
50
  limit=_int_arg(args, "limit", 20),
51
+ ),
52
+ repair_metadata,
48
53
  )
49
54
 
50
55
  async def invoke_task_output(_context: object, raw_input: str) -> str:
51
- args, error = _tool_args(raw_input, "task_output")
56
+ args, error, repair_metadata = _tool_args(raw_input, "task_output", TASK_OUTPUT_SCHEMA)
52
57
  if error is not None:
53
58
  return error
54
- return await asyncio.to_thread(
59
+ result = await asyncio.to_thread(
55
60
  runtime.task_output,
56
61
  _string_arg(args, "task_id"),
57
62
  block=_bool_arg(args, "block", False),
58
63
  timeout=_int_arg(args, "timeout", 3),
59
64
  )
65
+ return _merge_tool_result_metadata(result, repair_metadata)
60
66
 
61
67
  async def invoke_task_stop(_context: object, raw_input: str) -> str:
62
- args, error = _tool_args(raw_input, "task_stop")
68
+ args, error, repair_metadata = _tool_args(raw_input, "task_stop", TASK_STOP_SCHEMA)
63
69
  if error is not None:
64
70
  return error
65
- return runtime.task_stop(_string_arg(args, "task_id"))
71
+ return _merge_tool_result_metadata(
72
+ runtime.task_stop(_string_arg(args, "task_id")),
73
+ repair_metadata,
74
+ )
66
75
 
67
76
  async def invoke_ask_user_question(_context: object, raw_input: str) -> str:
68
- args, error = _tool_args(raw_input, "AskUserQuestion")
77
+ args, error, repair_metadata = _tool_args(
78
+ raw_input,
79
+ "AskUserQuestion",
80
+ ASK_USER_QUESTION_SCHEMA,
81
+ )
69
82
  if error is not None:
70
83
  return error
71
84
  questions = args.get("questions")
72
- return runtime.ask_user_question(questions if isinstance(questions, list) else [])
85
+ return _merge_tool_result_metadata(
86
+ runtime.ask_user_question(questions if isinstance(questions, list) else []),
87
+ repair_metadata,
88
+ )
73
89
 
74
90
  async def invoke_search(_context: object, raw_input: str) -> str:
75
- args, error = _tool_args(raw_input, "Search")
91
+ args, error, repair_metadata = _tool_args(raw_input, "Search", SEARCH_SCHEMA)
76
92
  if error is not None:
77
93
  return error
78
- return await asyncio.to_thread(
94
+ result = await asyncio.to_thread(
79
95
  runtime.search,
80
96
  _string_arg(args, "query"),
81
97
  path=_string_arg(args, "path") or ".",
@@ -88,24 +104,26 @@ def build_function_tools(
88
104
  offset=_int_arg(args, "offset", 0),
89
105
  include_ignored=_bool_arg(args, "include_ignored", False),
90
106
  )
107
+ return _merge_tool_result_metadata(result, repair_metadata)
91
108
 
92
109
  async def invoke_read_file(_context: object, raw_input: str) -> str:
93
- args, error = _tool_args(raw_input, "read_file")
110
+ args, error, repair_metadata = _tool_args(raw_input, "read_file", READ_FILE_SCHEMA)
94
111
  if error is not None:
95
112
  return error
96
- return await asyncio.to_thread(
113
+ result = await asyncio.to_thread(
97
114
  runtime.read_file,
98
115
  _string_arg(args, "file_path"),
99
116
  start_line=_int_arg(args, "offset", 1),
100
117
  limit=_optional_int_arg(args, "limit"),
101
118
  pages=_optional_string_arg(args, "pages"),
102
119
  )
120
+ return _merge_tool_result_metadata(result, repair_metadata)
103
121
 
104
122
  async def invoke_edit_text(_context: object, raw_input: str) -> str:
105
- args, error = _tool_args(raw_input, "edit_text")
123
+ args, error, repair_metadata = _tool_args(raw_input, "edit_text", EDIT_TEXT_SCHEMA)
106
124
  if error is not None:
107
125
  return error
108
- return await asyncio.to_thread(
126
+ result = await asyncio.to_thread(
109
127
  runtime.edit_text,
110
128
  _optional_string_arg(args, "file_path"),
111
129
  _string_arg(args, "old_string"),
@@ -114,49 +132,59 @@ def build_function_tools(
114
132
  snippet_id=_optional_string_arg(args, "snippet_id"),
115
133
  expected_occurrences=_optional_int_arg(args, "expected_occurrences"),
116
134
  )
135
+ return _merge_tool_result_metadata(result, repair_metadata)
117
136
 
118
137
  async def invoke_write_file(_context: object, raw_input: str) -> str:
119
- args, error = _tool_args(raw_input, "write_file")
138
+ args, error, repair_metadata = _tool_args(raw_input, "write_file", WRITE_FILE_SCHEMA)
120
139
  if error is not None:
121
140
  return error
122
- return await asyncio.to_thread(
141
+ result = await asyncio.to_thread(
123
142
  runtime.write_file,
124
143
  _string_arg(args, "file_path"),
125
144
  args.get("content"),
126
145
  overwrite=_bool_arg(args, "overwrite", False),
127
146
  snapshot_id=_optional_string_arg(args, "snapshot_id"),
128
147
  expected_hash=_optional_string_arg(args, "expected_hash"),
148
+ snapshot_token=_optional_int_arg(args, "snapshot_token"),
129
149
  )
150
+ return _merge_tool_result_metadata(result, repair_metadata)
130
151
 
131
152
  async def invoke_apply_patch(_context: object, raw_input: str) -> str:
132
- args, error = _tool_args(raw_input, "apply_patch")
153
+ args, error, repair_metadata = _tool_args(raw_input, "apply_patch", APPLY_PATCH_SCHEMA)
133
154
  if error is not None:
134
155
  return error
135
- return await asyncio.to_thread(runtime.apply_patch, args.get("operations"))
156
+ result = await asyncio.to_thread(runtime.apply_patch, args.get("operations"))
157
+ return _merge_tool_result_metadata(result, repair_metadata)
136
158
 
137
159
  async def invoke_web_search(_context: object, raw_input: str) -> str:
138
- args, error = _tool_args(raw_input, "WebSearch")
160
+ args, error, repair_metadata = _tool_args(raw_input, "WebSearch", WEB_SEARCH_SCHEMA)
139
161
  if error is not None:
140
162
  return error
141
- return await asyncio.to_thread(runtime.web_search, _string_arg(args, "query"))
163
+ result = await asyncio.to_thread(runtime.web_search, _string_arg(args, "query"))
164
+ return _merge_tool_result_metadata(result, repair_metadata)
142
165
 
143
166
  async def invoke_web_fetch(_context: object, raw_input: str) -> str:
144
- args, error = _tool_args(raw_input, "WebFetch")
167
+ args, error, repair_metadata = _tool_args(raw_input, "WebFetch", WEB_FETCH_SCHEMA)
145
168
  if error is not None:
146
169
  return error
147
- return await asyncio.to_thread(runtime.web_fetch, _string_arg(args, "url"))
170
+ result = await asyncio.to_thread(runtime.web_fetch, _string_arg(args, "url"))
171
+ return _merge_tool_result_metadata(result, repair_metadata)
148
172
 
149
173
  async def invoke_load_skill(_context: object, raw_input: str) -> str:
150
- args, error = _tool_args(raw_input, "load_skill")
174
+ args, error, repair_metadata = _tool_args(raw_input, "load_skill", LOAD_SKILL_SCHEMA)
151
175
  if error is not None:
152
176
  return error
153
- return await asyncio.to_thread(runtime.load_skill, _string_arg(args, "name"))
177
+ result = await asyncio.to_thread(runtime.load_skill, _string_arg(args, "name"))
178
+ return _merge_tool_result_metadata(result, repair_metadata)
154
179
 
155
180
  async def invoke_todo_write(_context: object, raw_input: str) -> str:
156
- args, error = _tool_args(raw_input, "todo_write")
181
+ args, error, repair_metadata = _tool_args(raw_input, "todo_write", TODO_WRITE_SCHEMA)
157
182
  if error is not None:
158
183
  return error
159
- return runtime.todo_write(args.get("todos") if "todos" in args else None)
184
+ return _merge_tool_result_metadata(
185
+ runtime.todo_write(args.get("todos") if "todos" in args else None),
186
+ repair_metadata,
187
+ )
160
188
 
161
189
  web_search_description = (
162
190
  "Perform web searching using a natural language query. Use a small number of "
@@ -260,7 +288,7 @@ def build_function_tools(
260
288
  name="write_file",
261
289
  description=(
262
290
  "Create a new text file or explicitly replace a whole file. Existing-file "
263
- "replacement requires overwrite intent plus snapshot_id or expected_hash."
291
+ "replacement requires overwrite intent plus snapshot_id, snapshot_token, or expected_hash."
264
292
  ),
265
293
  params_json_schema=WRITE_FILE_SCHEMA,
266
294
  on_invoke_tool=invoke_write_file,
@@ -361,34 +389,61 @@ def _schema_type_allows_null(schema: dict[str, Any]) -> bool:
361
389
  return isinstance(schema_type, list) and "null" in schema_type
362
390
 
363
391
 
364
- def _tool_args(raw_input: str, tool_name: str) -> tuple[dict[str, Any], str | None]:
392
+ def _tool_args(
393
+ raw_input: str,
394
+ tool_name: str,
395
+ schema: dict[str, Any],
396
+ ) -> tuple[dict[str, Any], str | None, dict[str, Any]]:
365
397
  try:
366
398
  parsed = json_utils.loads(raw_input or "{}")
367
399
  except json_utils.JSONDecodeError as exc:
368
- return {}, _invalid_tool_arguments_result(tool_name, exc)
400
+ repaired, repair_metadata = _repair_tool_arguments(raw_input or "")
401
+ if repaired is not None:
402
+ try:
403
+ parsed = json_utils.loads(repaired)
404
+ except json_utils.JSONDecodeError:
405
+ parsed = None
406
+ if isinstance(parsed, dict):
407
+ validation_error = _validate_args_against_schema(parsed, schema)
408
+ if validation_error is None:
409
+ return parsed, None, repair_metadata
410
+ return {}, _invalid_tool_arguments_result(tool_name, exc, repair_metadata), {}
369
411
  if not isinstance(parsed, dict):
370
412
  return {}, ToolResult.error_result(
371
413
  tool_name,
372
414
  "Invalid tool arguments JSON: expected a JSON object matching the tool schema.",
373
415
  metadata={
374
416
  "error_code": "invalid_arguments",
417
+ "retryable": True,
418
+ "recoverable": True,
419
+ "repairAttempted": False,
420
+ "repairApplied": False,
375
421
  "recovery": "Pass exactly one JSON object as the tool arguments.",
376
422
  },
377
- ).to_json()
378
- return parsed, None
423
+ ).to_json(), {}
424
+ return parsed, None, {}
379
425
 
380
426
 
381
- def _invalid_tool_arguments_result(tool_name: str, exc: json_utils.JSONDecodeError) -> str:
427
+ def _invalid_tool_arguments_result(
428
+ tool_name: str,
429
+ exc: json_utils.JSONDecodeError,
430
+ repair_metadata: dict[str, Any] | None = None,
431
+ ) -> str:
382
432
  return ToolResult.error_result(
383
433
  tool_name,
384
434
  "Invalid tool arguments JSON: "
385
435
  f"{exc.msg} at line {exc.lineno} column {exc.colno}.",
386
436
  metadata={
387
437
  "error_code": "invalid_arguments",
438
+ "retryable": True,
439
+ "recoverable": True,
388
440
  "parse_error": str(exc),
389
441
  "line": exc.lineno,
390
442
  "column": exc.colno,
391
443
  "position": exc.pos,
444
+ "repairAttempted": bool(repair_metadata),
445
+ "repairApplied": False,
446
+ **(repair_metadata or {}),
392
447
  "recovery": (
393
448
  "Pass a valid JSON object matching the tool schema. "
394
449
  'String values such as snapshot_id must be quoted, for example "snapshot_4".'
@@ -397,6 +452,184 @@ def _invalid_tool_arguments_result(tool_name: str, exc: json_utils.JSONDecodeErr
397
452
  ).to_json()
398
453
 
399
454
 
455
+ def _repair_tool_arguments(raw_input: str) -> tuple[str | None, dict[str, Any]]:
456
+ repaired = raw_input.strip()
457
+ if not repaired:
458
+ return None, {}
459
+ operations: list[str] = []
460
+ repaired, changed = _quote_known_unquoted_ids(repaired)
461
+ if changed:
462
+ operations.append("quote_tool_ids")
463
+ repaired, changed = _replace_unquoted_python_literals(repaired)
464
+ if changed:
465
+ operations.append("json_literals")
466
+ repaired, changed = _remove_trailing_commas(repaired)
467
+ if changed:
468
+ operations.append("trailing_commas")
469
+ if not operations:
470
+ return None, {}
471
+ return repaired, {
472
+ "argumentRepair": True,
473
+ "repairAttempted": True,
474
+ "repairApplied": True,
475
+ "repairOperations": operations,
476
+ }
477
+
478
+
479
+ def _quote_known_unquoted_ids(value: str) -> tuple[str, bool]:
480
+ pattern = re.compile(
481
+ r'("(?P<key>snapshot_id|snippet_id)"\s*:\s*)'
482
+ r'(?P<id>(?:snapshot|snippet)_\d+)(?=\s*[,}\]])'
483
+ )
484
+ repaired, count = pattern.subn(r'\1"\g<id>"', value)
485
+ return repaired, count > 0
486
+
487
+
488
+ def _replace_unquoted_python_literals(value: str) -> tuple[str, bool]:
489
+ replacements = {"None": "null", "True": "true", "False": "false"}
490
+ output: list[str] = []
491
+ changed = False
492
+ index = 0
493
+ in_string = False
494
+ escape = False
495
+ while index < len(value):
496
+ char = value[index]
497
+ if in_string:
498
+ output.append(char)
499
+ if escape:
500
+ escape = False
501
+ elif char == "\\":
502
+ escape = True
503
+ elif char == '"':
504
+ in_string = False
505
+ index += 1
506
+ continue
507
+ if char == '"':
508
+ in_string = True
509
+ output.append(char)
510
+ index += 1
511
+ continue
512
+ replaced = False
513
+ for source, target in replacements.items():
514
+ end = index + len(source)
515
+ if (
516
+ value.startswith(source, index)
517
+ and (index == 0 or not _is_identifier_char(value[index - 1]))
518
+ and (end >= len(value) or not _is_identifier_char(value[end]))
519
+ ):
520
+ output.append(target)
521
+ index = end
522
+ changed = True
523
+ replaced = True
524
+ break
525
+ if not replaced:
526
+ output.append(char)
527
+ index += 1
528
+ return "".join(output), changed
529
+
530
+
531
+ def _remove_trailing_commas(value: str) -> tuple[str, bool]:
532
+ output: list[str] = []
533
+ changed = False
534
+ index = 0
535
+ in_string = False
536
+ escape = False
537
+ while index < len(value):
538
+ char = value[index]
539
+ if in_string:
540
+ output.append(char)
541
+ if escape:
542
+ escape = False
543
+ elif char == "\\":
544
+ escape = True
545
+ elif char == '"':
546
+ in_string = False
547
+ index += 1
548
+ continue
549
+ if char == '"':
550
+ in_string = True
551
+ output.append(char)
552
+ index += 1
553
+ continue
554
+ if char == ",":
555
+ next_index = index + 1
556
+ while next_index < len(value) and value[next_index].isspace():
557
+ next_index += 1
558
+ if next_index < len(value) and value[next_index] in "}]":
559
+ changed = True
560
+ index += 1
561
+ continue
562
+ output.append(char)
563
+ index += 1
564
+ return "".join(output), changed
565
+
566
+
567
+ def _is_identifier_char(char: str) -> bool:
568
+ return char.isalnum() or char == "_"
569
+
570
+
571
+ def _validate_args_against_schema(args: dict[str, Any], schema: dict[str, Any]) -> str | None:
572
+ required = schema.get("required")
573
+ if isinstance(required, list):
574
+ for field in required:
575
+ if isinstance(field, str) and field not in args:
576
+ return f"Missing required field: {field}"
577
+ properties = schema.get("properties")
578
+ if not isinstance(properties, dict):
579
+ return None
580
+ if schema.get("additionalProperties") is False:
581
+ extra = sorted(set(args) - set(properties))
582
+ if extra:
583
+ return f"Unsupported fields: {', '.join(extra)}"
584
+ for key, value in args.items():
585
+ field_schema = properties.get(key)
586
+ if isinstance(field_schema, dict) and not _schema_value_matches(value, field_schema):
587
+ return f"Invalid type for field: {key}"
588
+ return None
589
+
590
+
591
+ def _schema_value_matches(value: Any, schema: dict[str, Any]) -> bool:
592
+ schema_type = schema.get("type")
593
+ allowed = schema_type if isinstance(schema_type, list) else [schema_type]
594
+ if value is None:
595
+ return "null" in allowed
596
+ if "string" in allowed and isinstance(value, str):
597
+ return _schema_enum_matches(value, schema)
598
+ if "boolean" in allowed and isinstance(value, bool):
599
+ return _schema_enum_matches(value, schema)
600
+ if "integer" in allowed and isinstance(value, int) and not isinstance(value, bool):
601
+ return _schema_enum_matches(value, schema)
602
+ if "number" in allowed and isinstance(value, int | float) and not isinstance(value, bool):
603
+ return _schema_enum_matches(value, schema)
604
+ if "array" in allowed and isinstance(value, list):
605
+ item_schema = schema.get("items")
606
+ return not isinstance(item_schema, dict) or all(_schema_value_matches(item, item_schema) for item in value)
607
+ if "object" in allowed and isinstance(value, dict):
608
+ nested_error = _validate_args_against_schema(value, schema)
609
+ return nested_error is None
610
+ return False
611
+
612
+
613
+ def _schema_enum_matches(value: Any, schema: dict[str, Any]) -> bool:
614
+ enum = schema.get("enum")
615
+ return not isinstance(enum, list) or value in enum
616
+
617
+
618
+ def _merge_tool_result_metadata(result: str, metadata: dict[str, Any]) -> str:
619
+ if not metadata:
620
+ return result
621
+ try:
622
+ payload = json_utils.loads(result)
623
+ except json_utils.JSONDecodeError:
624
+ return result
625
+ if not isinstance(payload, dict):
626
+ return result
627
+ result_metadata = payload.get("metadata")
628
+ merged = result_metadata if isinstance(result_metadata, dict) else {}
629
+ payload["metadata"] = {**merged, **metadata}
630
+ return json_utils.dumps(payload)
631
+
632
+
400
633
  def _string_arg(args: dict[str, Any], name: str) -> str:
401
634
  value = args.get(name)
402
635
  return value if isinstance(value, str) else ""
@@ -694,12 +927,23 @@ WRITE_FILE_SCHEMA: dict[str, Any] = {
694
927
  "type": ["string", "null"],
695
928
  "description": "Snapshot id returned by read_file for existing-file replacement.",
696
929
  },
930
+ "snapshot_token": {
931
+ "type": ["integer", "null"],
932
+ "description": "Numeric snapshot token returned by read_file for existing-file replacement.",
933
+ },
697
934
  "expected_hash": {
698
935
  "type": ["string", "null"],
699
936
  "description": "Content hash returned by read_file for existing-file replacement.",
700
937
  },
701
938
  },
702
- "required": ["file_path", "content", "overwrite", "snapshot_id", "expected_hash"],
939
+ "required": [
940
+ "file_path",
941
+ "content",
942
+ "overwrite",
943
+ "snapshot_id",
944
+ "snapshot_token",
945
+ "expected_hash",
946
+ ],
703
947
  "additionalProperties": False,
704
948
  }
705
949
 
@@ -760,6 +1004,10 @@ APPLY_PATCH_OPERATION_SCHEMA: dict[str, Any] = {
760
1004
  "type": ["string", "null"],
761
1005
  "description": "Snapshot id returned by read_file for replace_file.",
762
1006
  },
1007
+ "snapshot_token": {
1008
+ "type": ["integer", "null"],
1009
+ "description": "Numeric snapshot token returned by read_file for replace_file.",
1010
+ },
763
1011
  "expected_hash": {
764
1012
  "type": ["string", "null"],
765
1013
  "description": "Content hash returned by read_file for replace_file.",
@@ -777,6 +1025,7 @@ APPLY_PATCH_OPERATION_SCHEMA: dict[str, Any] = {
777
1025
  "replace_all",
778
1026
  "overwrite",
779
1027
  "snapshot_id",
1028
+ "snapshot_token",
780
1029
  "expected_hash",
781
1030
  ],
782
1031
  "additionalProperties": False,