yycode 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. agent/__init__.py +33 -0
  2. agent/acp/__init__.py +2 -0
  3. agent/acp/approval_adapter.py +134 -0
  4. agent/acp/content_adapter.py +45 -0
  5. agent/acp/jsonrpc.py +92 -0
  6. agent/acp/server.py +197 -0
  7. agent/acp/session_manager.py +193 -0
  8. agent/acp/update_adapter.py +192 -0
  9. agent/app_paths.py +25 -0
  10. agent/approval.py +169 -0
  11. agent/cancellation.py +52 -0
  12. agent/change_snapshot.py +186 -0
  13. agent/context_compressor.py +116 -0
  14. agent/graph.py +137 -0
  15. agent/llm_retry.py +434 -0
  16. agent/logger.py +97 -0
  17. agent/lsp/__init__.py +13 -0
  18. agent/lsp/client.py +151 -0
  19. agent/lsp/manager.py +234 -0
  20. agent/lsp/types.py +119 -0
  21. agent/message_context_manager.py +322 -0
  22. agent/message_format.py +105 -0
  23. agent/nodes/llm_node.py +58 -0
  24. agent/nodes/state.py +12 -0
  25. agent/nodes/task_guard_node.py +50 -0
  26. agent/nodes/tools_node.py +70 -0
  27. agent/plan_snapshot.py +70 -0
  28. agent/providers/__init__.py +13 -0
  29. agent/providers/anthropic_provider.py +268 -0
  30. agent/providers/base.py +52 -0
  31. agent/providers/openai_provider.py +279 -0
  32. agent/providers/text_tool_calls.py +118 -0
  33. agent/runtime/approval_service.py +184 -0
  34. agent/runtime/context.py +43 -0
  35. agent/runtime/tool_events.py +368 -0
  36. agent/runtime/tool_executor.py +208 -0
  37. agent/runtime/tool_output.py +261 -0
  38. agent/runtime/tool_registry.py +91 -0
  39. agent/runtime/tool_scheduler.py +35 -0
  40. agent/runtime/workflow_guard.py +217 -0
  41. agent/runtime/workspace.py +5 -0
  42. agent/runtime/workspace_tools.py +22 -0
  43. agent/session.py +787 -0
  44. agent/session_replay.py +95 -0
  45. agent/session_store.py +186 -0
  46. agent/skills.py +254 -0
  47. agent/streaming.py +248 -0
  48. agent/subagent.py +634 -0
  49. agent/task_memory.py +340 -0
  50. agent/todo_manager.py +304 -0
  51. agent/tool_retry.py +106 -0
  52. agent/tui/__init__.py +14 -0
  53. agent/tui/app.py +1325 -0
  54. agent/tui/approval.py +53 -0
  55. agent/tui/commands/__init__.py +6 -0
  56. agent/tui/commands/base.py +48 -0
  57. agent/tui/commands/clear.py +37 -0
  58. agent/tui/commands/help.py +27 -0
  59. agent/tui/commands/registry.py +94 -0
  60. agent/tui/help_content.py +108 -0
  61. agent/tui/renderers.py +1961 -0
  62. agent/tui/runner.py +439 -0
  63. agent/tui/state.py +653 -0
  64. main.py +465 -0
  65. tools/__init__.py +50 -0
  66. tools/apply_patch.py +305 -0
  67. tools/bash.py +76 -0
  68. tools/diff_utils.py +139 -0
  69. tools/edit_file.py +40 -0
  70. tools/git_diff.py +72 -0
  71. tools/git_show.py +65 -0
  72. tools/grep.py +149 -0
  73. tools/list_files.py +90 -0
  74. tools/list_skills.py +24 -0
  75. tools/load_skill.py +30 -0
  76. tools/lsp_definition.py +27 -0
  77. tools/lsp_diagnostics.py +32 -0
  78. tools/lsp_document_symbols.py +23 -0
  79. tools/lsp_hover.py +29 -0
  80. tools/lsp_references.py +37 -0
  81. tools/lsp_utils.py +38 -0
  82. tools/lsp_workspace_symbols.py +23 -0
  83. tools/read_file.py +61 -0
  84. tools/read_many_files.py +50 -0
  85. tools/safety.py +50 -0
  86. tools/subagent.py +57 -0
  87. tools/todo.py +89 -0
  88. tools/verify.py +107 -0
  89. tools/web_search.py +250 -0
  90. tools/workspace.py +36 -0
  91. tools/workspace_state.py +60 -0
  92. tools/write_file.py +88 -0
  93. utils/__init__.py +5 -0
  94. utils/retry.py +13 -0
  95. yycode-0.3.2.data/data/skills/code_review.md +61 -0
  96. yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
  97. yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
  98. yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
  99. yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
  100. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
  101. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
  102. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
  103. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
  104. yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
  105. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
  106. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
  107. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
  108. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
  109. yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
  110. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
  111. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
  112. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
  113. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
  114. yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
  115. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
  116. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
  117. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
  118. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
  119. yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
  120. yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
  121. yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
  122. yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
  123. yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
  124. yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
  125. yycode-0.3.2.data/data/skills/plan.md +115 -0
  126. yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
  127. yycode-0.3.2.dist-info/METADATA +12 -0
  128. yycode-0.3.2.dist-info/RECORD +131 -0
  129. yycode-0.3.2.dist-info/WHEEL +5 -0
  130. yycode-0.3.2.dist-info/entry_points.txt +2 -0
  131. yycode-0.3.2.dist-info/top_level.txt +4 -0
tools/grep.py ADDED
@@ -0,0 +1,149 @@
1
+ """Python grep search tool."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from . import read_file
7
+
8
+ MAX_OUTPUT_CHARS = 50_000
9
+ SKIP_DIRS = {
10
+ ".git",
11
+ ".hg",
12
+ ".mypy_cache",
13
+ ".pytest_cache",
14
+ ".ruff_cache",
15
+ ".venv",
16
+ "__pycache__",
17
+ "node_modules",
18
+ }
19
+
20
+
21
+ def _iter_files(path: Path):
22
+ if path.is_file():
23
+ yield path
24
+ return
25
+ for child in path.iterdir():
26
+ if child.is_dir():
27
+ if child.name in SKIP_DIRS or child.name.startswith("."):
28
+ continue
29
+ yield from _iter_files(child)
30
+ elif child.is_file():
31
+ yield child
32
+
33
+
34
+ def _read_text(path: Path) -> str | None:
35
+ try:
36
+ data = path.read_bytes()
37
+ except OSError:
38
+ return None
39
+ if b"\x00" in data:
40
+ return None
41
+ try:
42
+ return data.decode("utf-8")
43
+ except UnicodeDecodeError:
44
+ try:
45
+ return data.decode("utf-8", errors="replace")
46
+ except UnicodeDecodeError:
47
+ return None
48
+
49
+
50
+ def _format_match_with_context(
51
+ relative_path: Path,
52
+ lines: list[str],
53
+ line_number: int,
54
+ before_context: int,
55
+ after_context: int,
56
+ ) -> str:
57
+ if before_context <= 0 and after_context <= 0:
58
+ return f"{relative_path}:{line_number}:{lines[line_number - 1]}"
59
+
60
+ start = max(line_number - before_context, 1)
61
+ end = min(line_number + after_context, len(lines))
62
+ section = [f"{relative_path}:{line_number}:"]
63
+ for current in range(start, end + 1):
64
+ marker = ">" if current == line_number else " "
65
+ section.append(f"{marker} {current}: {lines[current - 1]}")
66
+ return "\n".join(section)
67
+
68
+
69
+ def grep(
70
+ pattern: str,
71
+ path: str = ".",
72
+ max_results: int = 100,
73
+ before_context: int = 0,
74
+ after_context: int = 0,
75
+ workdir: Path | str | None = None,
76
+ ) -> str:
77
+ """Search workspace files using Python regex matching."""
78
+ try:
79
+ workspace = read_file.workspace_for(workdir)
80
+ search_path = workspace.safe_path(path)
81
+ max_results = max(1, min(int(max_results), 500))
82
+ before_context = max(0, min(int(before_context), 20))
83
+ after_context = max(0, min(int(after_context), 20))
84
+ regex = re.compile(pattern)
85
+ matches = []
86
+ for file_path in _iter_files(search_path):
87
+ text = _read_text(file_path)
88
+ if text is None:
89
+ continue
90
+ relative_path = file_path.relative_to(workspace.root)
91
+ lines = text.splitlines()
92
+ for line_number, line in enumerate(lines, start=1):
93
+ if regex.search(line):
94
+ matches.append(
95
+ _format_match_with_context(
96
+ relative_path,
97
+ lines,
98
+ line_number,
99
+ before_context,
100
+ after_context,
101
+ )
102
+ )
103
+ if len(matches) >= max_results:
104
+ output = "\n\n".join(matches)
105
+ return output[:MAX_OUTPUT_CHARS]
106
+
107
+ output = "\n\n".join(matches)
108
+ return output[:MAX_OUTPUT_CHARS] if output else "No matches found."
109
+ except re.error as exc:
110
+ return f"Error: invalid regex pattern: {exc}"
111
+ except Exception as exc:
112
+ return f"Error: {exc}"
113
+
114
+
115
+ grep_tool = {
116
+ "name": "grep",
117
+ "description": "A Python-powered grep tool for searching workspace files with regular expressions.",
118
+ "execution": {
119
+ "side_effects": "read_only",
120
+ "concurrency": "safe",
121
+ "timeout_seconds": 30,
122
+ },
123
+ "input_schema": {
124
+ "type": "object",
125
+ "properties": {
126
+ "pattern": {
127
+ "type": "string",
128
+ "description": "Python regular expression pattern.",
129
+ },
130
+ "path": {
131
+ "type": "string",
132
+ "description": "Workspace-relative path to search. Defaults to current workspace.",
133
+ },
134
+ "max_results": {
135
+ "type": "integer",
136
+ "description": "Maximum matches per file, capped at 500. Defaults to 100.",
137
+ },
138
+ "before_context": {
139
+ "type": "integer",
140
+ "description": "Number of lines to include before each match, capped at 20.",
141
+ },
142
+ "after_context": {
143
+ "type": "integer",
144
+ "description": "Number of lines to include after each match, capped at 20.",
145
+ },
146
+ },
147
+ "required": ["pattern"],
148
+ },
149
+ }
tools/list_files.py ADDED
@@ -0,0 +1,90 @@
1
+ """List workspace files without shelling out."""
2
+
3
+ import fnmatch
4
+ from pathlib import Path
5
+
6
+ from . import read_file
7
+
8
+ MAX_RESULTS = 500
9
+ SKIP_DIRS = {
10
+ ".git",
11
+ ".hg",
12
+ ".mypy_cache",
13
+ ".pytest_cache",
14
+ ".ruff_cache",
15
+ ".venv",
16
+ "__pycache__",
17
+ "node_modules",
18
+ }
19
+
20
+
21
+ def _iter_files(path: Path, max_depth: int | None, depth: int = 0):
22
+ if path.is_file():
23
+ yield path
24
+ return
25
+ if max_depth is not None and depth > max_depth:
26
+ return
27
+ for child in sorted(path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
28
+ if child.is_dir():
29
+ if child.name in SKIP_DIRS or child.name.startswith("."):
30
+ continue
31
+ yield from _iter_files(child, max_depth, depth + 1)
32
+ elif child.is_file():
33
+ yield child
34
+
35
+
36
+ def list_files(
37
+ path: str = ".",
38
+ pattern: str = "*",
39
+ max_results: int = 200,
40
+ max_depth: int | None = None,
41
+ workdir: Path | str | None = None,
42
+ ) -> str:
43
+ """List workspace-relative files, optionally filtered by glob pattern."""
44
+ try:
45
+ workspace = read_file.workspace_for(workdir)
46
+ root = workspace.safe_path(path)
47
+ max_results = max(1, min(int(max_results), MAX_RESULTS))
48
+ files = []
49
+ for file_path in _iter_files(root, max_depth):
50
+ relative_path = str(file_path.relative_to(workspace.root))
51
+ if fnmatch.fnmatch(relative_path, pattern) or fnmatch.fnmatch(file_path.name, pattern):
52
+ files.append(relative_path)
53
+ if len(files) >= max_results:
54
+ break
55
+ return "\n".join(files) if files else "No files found."
56
+ except Exception as exc:
57
+ return f"Error: {exc}"
58
+
59
+
60
+ list_files_tool = {
61
+ "name": "list_files",
62
+ "description": "List workspace files using Python glob matching without running shell commands.",
63
+ "execution": {
64
+ "side_effects": "read_only",
65
+ "concurrency": "safe",
66
+ "timeout_seconds": 30,
67
+ },
68
+ "input_schema": {
69
+ "type": "object",
70
+ "properties": {
71
+ "path": {
72
+ "type": "string",
73
+ "description": "Workspace-relative directory or file path. Defaults to current workspace.",
74
+ },
75
+ "pattern": {
76
+ "type": "string",
77
+ "description": "Glob pattern matched against relative paths or file names. Defaults to *.",
78
+ },
79
+ "max_results": {
80
+ "type": "integer",
81
+ "description": "Maximum number of files to return, capped at 500. Defaults to 200.",
82
+ },
83
+ "max_depth": {
84
+ "type": "integer",
85
+ "description": "Optional maximum directory depth from the requested path.",
86
+ },
87
+ },
88
+ "required": [],
89
+ },
90
+ }
tools/list_skills.py ADDED
@@ -0,0 +1,24 @@
1
+ """List skills tool - tool definition only."""
2
+
3
+ list_skills_tool = {
4
+ "name": "list_skills",
5
+ "description": (
6
+ "List available local skills with their names and descriptions. "
7
+ "Use this before loading a skill when you are unsure what exists."
8
+ ),
9
+ "execution": {
10
+ "side_effects": "read_only",
11
+ "concurrency": "safe",
12
+ "timeout_seconds": 30,
13
+ },
14
+ "input_schema": {
15
+ "type": "object",
16
+ "properties": {},
17
+ "required": [],
18
+ },
19
+ }
20
+
21
+
22
+ def list_skills() -> str:
23
+ """Dummy list_skills handler - should be bound by the graph at runtime."""
24
+ raise RuntimeError("list_skills tool handler should be created by SkillRegistry")
tools/load_skill.py ADDED
@@ -0,0 +1,30 @@
1
+ """Load skill tool - tool definition only."""
2
+
3
+ load_skill_tool = {
4
+ "name": "load_skill",
5
+ "description": (
6
+ "Load the full content of one or more local skills by name or path. "
7
+ "Use this after list_skills when you need the full instructions."
8
+ ),
9
+ "execution": {
10
+ "side_effects": "read_only",
11
+ "concurrency": "safe",
12
+ "timeout_seconds": 30,
13
+ },
14
+ "input_schema": {
15
+ "type": "object",
16
+ "properties": {
17
+ "names": {
18
+ "type": "array",
19
+ "items": {"type": "string"},
20
+ "description": "Skill names or paths to load.",
21
+ },
22
+ },
23
+ "required": ["names"],
24
+ },
25
+ }
26
+
27
+
28
+ def load_skill(names: list[str]) -> str:
29
+ """Dummy load_skill handler - should be bound by the graph at runtime."""
30
+ raise RuntimeError("load_skill tool handler should be created by SkillRegistry")
@@ -0,0 +1,27 @@
1
+ """LSP definition lookup tool."""
2
+
3
+ from pathlib import Path
4
+
5
+ from tools.lsp_utils import LSP_TOOL_EXECUTION, format_list, run_lsp_tool
6
+
7
+
8
+ async def lsp_definition(path: str, line: int, character: int, workdir: Path | str | None = None) -> str:
9
+ """Find definitions for a Python symbol position using LSP."""
10
+ result = await run_lsp_tool(workdir, lambda manager: manager.definition(path, line, character))
11
+ return result if isinstance(result, str) else format_list("definitions", result)
12
+
13
+
14
+ lsp_definition_tool = {
15
+ "name": "lsp_definition",
16
+ "description": "Find definition locations for a Python symbol position using LSP. Line and character are zero-based.",
17
+ "execution": LSP_TOOL_EXECUTION,
18
+ "input_schema": {
19
+ "type": "object",
20
+ "properties": {
21
+ "path": {"type": "string", "description": "Workspace-relative Python file path."},
22
+ "line": {"type": "integer", "description": "Zero-based line number."},
23
+ "character": {"type": "integer", "description": "Zero-based character offset."},
24
+ },
25
+ "required": ["path", "line", "character"],
26
+ },
27
+ }
@@ -0,0 +1,32 @@
1
+ """LSP diagnostics tool."""
2
+
3
+ from pathlib import Path
4
+
5
+ from tools.lsp_utils import LSP_TOOL_EXECUTION, format_list, run_lsp_tool
6
+
7
+
8
+ async def lsp_diagnostics(path: str | None = None, workdir: Path | str | None = None) -> str:
9
+ """Return diagnostics reported by the Python LSP server when available."""
10
+ result = await run_lsp_tool(workdir, lambda manager: manager.diagnostics(path))
11
+ if isinstance(result, str):
12
+ return result
13
+ if not result:
14
+ return (
15
+ "status: unsupported\n"
16
+ "diagnostics: none\n"
17
+ "reason: pull diagnostics are not implemented in the current LSP MVP; "
18
+ "use verify for authoritative validation."
19
+ )
20
+ return format_list("diagnostics", result)
21
+
22
+
23
+ lsp_diagnostics_tool = {
24
+ "name": "lsp_diagnostics",
25
+ "description": "Return Python LSP diagnostics when available. MVP may return no_results for servers without pull diagnostics.",
26
+ "execution": LSP_TOOL_EXECUTION,
27
+ "input_schema": {
28
+ "type": "object",
29
+ "properties": {"path": {"type": "string", "description": "Optional workspace-relative Python file path."}},
30
+ "required": [],
31
+ },
32
+ }
@@ -0,0 +1,23 @@
1
+ """LSP document symbols tool."""
2
+
3
+ from pathlib import Path
4
+
5
+ from tools.lsp_utils import LSP_TOOL_EXECUTION, format_list, run_lsp_tool
6
+
7
+
8
+ async def lsp_document_symbols(path: str, workdir: Path | str | None = None) -> str:
9
+ """List symbols in a Python file using LSP."""
10
+ result = await run_lsp_tool(workdir, lambda manager: manager.document_symbols(path))
11
+ return result if isinstance(result, str) else format_list("symbols", result)
12
+
13
+
14
+ lsp_document_symbols_tool = {
15
+ "name": "lsp_document_symbols",
16
+ "description": "List classes, functions, methods, and variables in a Python file using LSP semantic navigation.",
17
+ "execution": LSP_TOOL_EXECUTION,
18
+ "input_schema": {
19
+ "type": "object",
20
+ "properties": {"path": {"type": "string", "description": "Workspace-relative Python file path."}},
21
+ "required": ["path"],
22
+ },
23
+ }
tools/lsp_hover.py ADDED
@@ -0,0 +1,29 @@
1
+ """LSP hover tool."""
2
+
3
+ from pathlib import Path
4
+
5
+ from tools.lsp_utils import LSP_TOOL_EXECUTION, run_lsp_tool
6
+
7
+
8
+ async def lsp_hover(path: str, line: int, character: int, workdir: Path | str | None = None) -> str:
9
+ """Return hover text for a Python symbol position using LSP."""
10
+ result = await run_lsp_tool(workdir, lambda manager: manager.hover(path, line, character))
11
+ if isinstance(result, str) and result.startswith(("status:", "Error:")):
12
+ return result
13
+ return "hover:\n" + (str(result).strip() or "none")
14
+
15
+
16
+ lsp_hover_tool = {
17
+ "name": "lsp_hover",
18
+ "description": "Return type/signature/documentation hover text for a Python symbol position using LSP. Line and character are zero-based.",
19
+ "execution": LSP_TOOL_EXECUTION,
20
+ "input_schema": {
21
+ "type": "object",
22
+ "properties": {
23
+ "path": {"type": "string", "description": "Workspace-relative Python file path."},
24
+ "line": {"type": "integer", "description": "Zero-based line number."},
25
+ "character": {"type": "integer", "description": "Zero-based character offset."},
26
+ },
27
+ "required": ["path", "line", "character"],
28
+ },
29
+ }
@@ -0,0 +1,37 @@
1
+ """LSP references lookup tool."""
2
+
3
+ from pathlib import Path
4
+
5
+ from tools.lsp_utils import LSP_TOOL_EXECUTION, format_list, run_lsp_tool
6
+
7
+
8
+ async def lsp_references(
9
+ path: str,
10
+ line: int,
11
+ character: int,
12
+ include_declaration: bool = False,
13
+ workdir: Path | str | None = None,
14
+ ) -> str:
15
+ """Find references for a Python symbol position using LSP."""
16
+ result = await run_lsp_tool(
17
+ workdir,
18
+ lambda manager: manager.references(path, line, character, include_declaration),
19
+ )
20
+ return result if isinstance(result, str) else format_list("references", result)
21
+
22
+
23
+ lsp_references_tool = {
24
+ "name": "lsp_references",
25
+ "description": "Find reference locations for a Python symbol position using LSP. Line and character are zero-based.",
26
+ "execution": LSP_TOOL_EXECUTION,
27
+ "input_schema": {
28
+ "type": "object",
29
+ "properties": {
30
+ "path": {"type": "string", "description": "Workspace-relative Python file path."},
31
+ "line": {"type": "integer", "description": "Zero-based line number."},
32
+ "character": {"type": "integer", "description": "Zero-based character offset."},
33
+ "include_declaration": {"type": "boolean", "description": "Whether to include the declaration location."},
34
+ },
35
+ "required": ["path", "line", "character"],
36
+ },
37
+ }
tools/lsp_utils.py ADDED
@@ -0,0 +1,38 @@
1
+ """Shared helpers for thin LSP tool wrappers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Awaitable, Callable, TypeVar
7
+
8
+ from agent.lsp.client import LspClientError
9
+ from agent.lsp.manager import LspUnavailable, get_lsp_manager
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ async def run_lsp_tool(workdir: Path | str | None, action: Callable[[object], Awaitable[T]]) -> T | str:
15
+ """Run an LSP action with model-friendly unavailable/error output."""
16
+ try:
17
+ manager = get_lsp_manager(workdir or Path.cwd())
18
+ return await action(manager)
19
+ except LspUnavailable as exc:
20
+ return "status: unavailable\nreason: " + str(exc) + "\nfallback: use grep and read_file for text-based navigation"
21
+ except (LspClientError, TimeoutError) as exc:
22
+ return "status: error\nreason: " + str(exc) + "\nfallback: use grep and read_file for text-based navigation"
23
+ except Exception as exc:
24
+ return f"Error: {exc}"
25
+
26
+
27
+ def format_list(title: str, items: list) -> str:
28
+ """Format LSP result items that expose format()."""
29
+ if not items:
30
+ return f"status: no_results\n{title}: none"
31
+ return title + ":\n" + "\n".join(f"- {item.format()}" for item in items)
32
+
33
+
34
+ LSP_TOOL_EXECUTION = {
35
+ "side_effects": "read_only",
36
+ "concurrency": "safe",
37
+ "timeout_seconds": 30,
38
+ }
@@ -0,0 +1,23 @@
1
+ """LSP workspace symbols tool."""
2
+
3
+ from pathlib import Path
4
+
5
+ from tools.lsp_utils import LSP_TOOL_EXECUTION, format_list, run_lsp_tool
6
+
7
+
8
+ async def lsp_workspace_symbols(query: str, workdir: Path | str | None = None) -> str:
9
+ """Search workspace symbols using LSP."""
10
+ result = await run_lsp_tool(workdir, lambda manager: manager.workspace_symbols(query))
11
+ return result if isinstance(result, str) else format_list("symbols", result)
12
+
13
+
14
+ lsp_workspace_symbols_tool = {
15
+ "name": "lsp_workspace_symbols",
16
+ "description": "Search Python workspace symbols by name using LSP semantic navigation.",
17
+ "execution": LSP_TOOL_EXECUTION,
18
+ "input_schema": {
19
+ "type": "object",
20
+ "properties": {"query": {"type": "string", "description": "Symbol name or query text."}},
21
+ "required": ["query"],
22
+ },
23
+ }
tools/read_file.py ADDED
@@ -0,0 +1,61 @@
1
+ """Read file tool."""
2
+
3
+ from pathlib import Path
4
+
5
+ from .workspace import Workspace
6
+
7
+
8
+ def workspace_for(workdir: Path | str | None = None) -> Workspace:
9
+ """Return the workspace for a tool call."""
10
+ return Workspace(Path(workdir) if workdir is not None else Path.cwd())
11
+
12
+
13
+ def safe_path(p: str, workdir: Path | str | None = None) -> Path:
14
+ """Get a safe path within the workspace."""
15
+ return workspace_for(workdir).safe_path(p)
16
+
17
+
18
+ def read_file(
19
+ path: str,
20
+ limit: int = None,
21
+ start_line: int | None = None,
22
+ end_line: int | None = None,
23
+ workdir: Path | str | None = None,
24
+ ) -> str:
25
+ """Read file contents."""
26
+ try:
27
+ text = safe_path(path, workdir).read_text()
28
+ lines = text.splitlines()
29
+ if start_line is not None or end_line is not None:
30
+ start = max((start_line or 1) - 1, 0)
31
+ end = max(end_line or len(lines), 0)
32
+ if end < start + 1:
33
+ return "Error: end_line must be greater than or equal to start_line"
34
+ selected = lines[start:end]
35
+ return "\n".join(selected)[:50000]
36
+ if limit and limit < len(lines):
37
+ lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
38
+ return "\n".join(lines)[:50000]
39
+ except Exception as e:
40
+ return f"Error: {e}"
41
+
42
+
43
+ read_file_tool = {
44
+ "name": "read_file",
45
+ "description": "Read file contents.",
46
+ "execution": {
47
+ "side_effects": "read_only",
48
+ "concurrency": "safe",
49
+ "timeout_seconds": 30,
50
+ },
51
+ "input_schema": {
52
+ "type": "object",
53
+ "properties": {
54
+ "path": {"type": "string"},
55
+ "limit": {"type": "integer"},
56
+ "start_line": {"type": "integer"},
57
+ "end_line": {"type": "integer"},
58
+ },
59
+ "required": ["path"],
60
+ },
61
+ }
@@ -0,0 +1,50 @@
1
+ """Read multiple workspace files in one tool call."""
2
+
3
+ from .read_file import read_file
4
+
5
+ from pathlib import Path
6
+
7
+ MAX_FILES = 20
8
+ MAX_OUTPUT_CHARS = 80_000
9
+
10
+
11
+ def read_many_files(paths: list[str], limit: int | None = None, workdir: Path | str | None = None) -> str:
12
+ """Read several files and separate each result with a header."""
13
+ try:
14
+ if not paths:
15
+ return "Error: paths is required"
16
+ selected_paths = paths[:MAX_FILES]
17
+ sections = []
18
+ for path in selected_paths:
19
+ sections.append(f"--- {path} ---\n{read_file(path, limit=limit, workdir=workdir)}")
20
+ if len(paths) > MAX_FILES:
21
+ sections.append(f"... skipped {len(paths) - MAX_FILES} file(s); max is {MAX_FILES}")
22
+ return "\n\n".join(sections)[:MAX_OUTPUT_CHARS]
23
+ except Exception as exc:
24
+ return f"Error: {exc}"
25
+
26
+
27
+ read_many_files_tool = {
28
+ "name": "read_many_files",
29
+ "description": "Read multiple workspace files at once with per-file headers.",
30
+ "execution": {
31
+ "side_effects": "read_only",
32
+ "concurrency": "safe",
33
+ "timeout_seconds": 30,
34
+ },
35
+ "input_schema": {
36
+ "type": "object",
37
+ "properties": {
38
+ "paths": {
39
+ "type": "array",
40
+ "items": {"type": "string"},
41
+ "description": "Workspace-relative file paths to read, capped at 20.",
42
+ },
43
+ "limit": {
44
+ "type": "integer",
45
+ "description": "Optional maximum number of lines per file.",
46
+ },
47
+ },
48
+ "required": ["paths"],
49
+ },
50
+ }
tools/safety.py ADDED
@@ -0,0 +1,50 @@
1
+ """Safety helpers for tools that need approval-style blocking."""
2
+
3
+ import re
4
+
5
+
6
+ class ApprovalRequired(Exception):
7
+ """Raised when an action should be blocked until user approval exists."""
8
+
9
+
10
+ def approval_required(action: str, reason: str, risk: str, command: str = "", path: str = "") -> str:
11
+ """Format a stable approval_required response for tools."""
12
+ lines = [
13
+ "approval_required:",
14
+ f"action: {action}",
15
+ ]
16
+ if command:
17
+ lines.append(f"command: {command}")
18
+ if path:
19
+ lines.append(f"path: {path}")
20
+ lines.extend(
21
+ [
22
+ f"reason: {reason}",
23
+ f"risk: {risk}",
24
+ ]
25
+ )
26
+ return "\n".join(lines)
27
+
28
+
29
+ DANGEROUS_COMMAND_PATTERNS = [
30
+ (r"\bsudo\b", "privileged_command", "sudo can run commands with elevated privileges."),
31
+ (r"\brm\s+.*(-r|-f|--recursive|--force)", "destructive_delete", "recursive or forced deletion can remove user work."),
32
+ (r"\bgit\s+reset\b", "destructive_git", "git reset can discard commits or local changes."),
33
+ (r"\bgit\s+checkout\b", "destructive_git", "git checkout can overwrite working tree files."),
34
+ (r"\bgit\s+clean\b", "destructive_git", "git clean deletes untracked files."),
35
+ (r"\bchmod\b|\bchown\b", "permission_change", "permission changes can break the workspace or expose files."),
36
+ (r">\s*/dev/", "device_write", "writing to device paths can damage the system."),
37
+ ]
38
+
39
+
40
+ def unsafe_command_response(command: str) -> str | None:
41
+ """Return an approval_required message if command matches high-risk patterns."""
42
+ for pattern, action, reason in DANGEROUS_COMMAND_PATTERNS:
43
+ if re.search(pattern, command):
44
+ return approval_required(
45
+ action=action,
46
+ command=command,
47
+ reason=reason,
48
+ risk="This operation may be destructive or affect files outside the intended task.",
49
+ )
50
+ return None