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.
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/PKG-INFO +1 -1
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/pyproject.toml +1 -1
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/__init__.py +1 -1
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/apply_patch.md +4 -1
- deepy_cli-0.2.17/src/deepy/data/tools/read_file.md +19 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/write_file.md +3 -2
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/system.py +1 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/agents.py +284 -35
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/builtin.py +59 -10
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/file_state.py +8 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/app.py +51 -6
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/widgets.py +36 -2
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/message_view.py +82 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/terminal.py +2 -1
- deepy_cli-0.2.16/src/deepy/data/tools/read_file.md +0 -16
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/README.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/__main__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/background_tasks.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/cli.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/config/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/config/settings.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/AskUserQuestion.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/Search.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/WebFetch.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/WebSearch.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/edit_text.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/shell.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/task_list.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/task_output.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/task_stop.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/data/tools/todo_write.md +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/errors.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/input_suggestions.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/agent.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/compaction.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/context.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/events.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/model_capabilities.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/provider.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/replay.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/runner.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/llm/thinking.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/mcp.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/compact.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/init_agents.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/rules.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/runtime_context.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/prompts/tool_docs.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/session_cost.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/sessions/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/sessions/jsonl.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/sessions/manager.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/skill_market.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/skills.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/status.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/todos.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/result.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/search.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/shell_output.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tools/shell_utils.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/commands.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/compat.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/diff.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/runner.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/screens.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/tui/state.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/types/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/types/sdk.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/types/tool_payloads.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/app.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/ask_user_question.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/exit_summary.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/file_mentions.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/loading_text.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/local_command.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/markdown.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/model_picker.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/prompt_buffer.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/prompt_input.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/session_list.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/session_picker.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/skill_picker.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/slash_commands.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/status_footer.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/styles.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/theme_picker.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/thinking_state.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/ui/welcome.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/update_check.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/usage.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/utils/__init__.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/utils/debug_logger.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/utils/error_logger.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/utils/json.py +0 -0
- {deepy_cli-0.2.16 → deepy_cli-0.2.17}/src/deepy/utils/notify.py +0 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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": [
|
|
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,
|