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/subagent.py ADDED
@@ -0,0 +1,57 @@
1
+ """Subagent delegation tool - tool definition only."""
2
+
3
+ subagent_tool = {
4
+ "name": "subagent",
5
+ "description": (
6
+ "Delegate a focused task to an isolated subagent and wait for its summary result. "
7
+ "Use explorer for research, architect for design, worker for implementation, "
8
+ "tester for verification, and security for security review."
9
+ ),
10
+ "execution": {
11
+ "side_effects": "delegation",
12
+ "concurrency": "role_based",
13
+ "timeout_seconds": 300,
14
+ },
15
+ "input_schema": {
16
+ "type": "object",
17
+ "properties": {
18
+ "role": {
19
+ "type": "string",
20
+ "enum": ["explorer", "architect", "worker", "tester", "security"],
21
+ "description": "Subagent role to run.",
22
+ },
23
+ "task": {
24
+ "type": "string",
25
+ "description": "The concrete task the subagent should complete.",
26
+ },
27
+ "context": {
28
+ "type": "string",
29
+ "description": "Optional extra context, constraints, or relevant findings.",
30
+ },
31
+ "max_turns": {
32
+ "type": "integer",
33
+ "description": "Optional recursion limit for the subagent run. Defaults to 30.",
34
+ },
35
+ "skills": {
36
+ "type": "array",
37
+ "items": {"type": "string"},
38
+ "description": (
39
+ "Optional local skill names to load into the subagent context before it starts. "
40
+ "Use this for explicit delegation such as @architect /plan task."
41
+ ),
42
+ },
43
+ },
44
+ "required": ["role", "task"],
45
+ },
46
+ }
47
+
48
+
49
+ def subagent(
50
+ role: str,
51
+ task: str,
52
+ context: str = "",
53
+ max_turns: int = 30,
54
+ skills: list[str] | None = None,
55
+ ) -> str:
56
+ """Dummy subagent handler - should be bound by the graph at runtime."""
57
+ raise RuntimeError("Subagent tool handler should be created by SubagentRunner")
tools/todo.py ADDED
@@ -0,0 +1,89 @@
1
+ """Task state tracking tool - tool definition only."""
2
+
3
+
4
+ TASK_MEMORY_PROPERTIES = {
5
+ "user_goal": {
6
+ "type": "string",
7
+ "description": "The current user goal in one concise sentence.",
8
+ },
9
+ "constraints": {
10
+ "type": "array",
11
+ "items": {"type": "string"},
12
+ "description": "Important constraints, preferences, or non-goals.",
13
+ },
14
+ "files_inspected": {
15
+ "type": "array",
16
+ "items": {"type": "string"},
17
+ "description": "Files or paths already inspected.",
18
+ },
19
+ "files_modified": {
20
+ "type": "array",
21
+ "items": {"type": "string"},
22
+ "description": "Files or paths modified during this task.",
23
+ },
24
+ "decisions": {
25
+ "type": "array",
26
+ "items": {"type": "string"},
27
+ "description": "Design or implementation decisions already made.",
28
+ },
29
+ "test_results": {
30
+ "type": "array",
31
+ "items": {"type": "string"},
32
+ "description": "Verification commands run and their results.",
33
+ },
34
+ "open_risks": {
35
+ "type": "array",
36
+ "items": {"type": "string"},
37
+ "description": "Known risks, uncertainties, or follow-up concerns.",
38
+ },
39
+ "next_steps": {
40
+ "type": "array",
41
+ "items": {"type": "string"},
42
+ "description": "Immediate next steps if the task continues.",
43
+ },
44
+ }
45
+
46
+ todo_tool = {
47
+ "name": "todo",
48
+ "description": (
49
+ "Update Task State for multi-step work. Track todo items plus compact task "
50
+ "memory such as goal, constraints, inspected/modified files, decisions, "
51
+ "test results, risks, and next steps."
52
+ ),
53
+ "execution": {
54
+ "side_effects": "session_state",
55
+ "concurrency": "serial",
56
+ "timeout_seconds": 30,
57
+ },
58
+ "input_schema": {
59
+ "type": "object",
60
+ "properties": {
61
+ "items": {
62
+ "type": "array",
63
+ "description": "Current ordered task checklist.",
64
+ "items": {
65
+ "type": "object",
66
+ "properties": {
67
+ "id": {"type": "string"},
68
+ "text": {"type": "string"},
69
+ "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
70
+ },
71
+ "required": ["id", "text", "status"],
72
+ },
73
+ },
74
+ "memory": {
75
+ "type": "object",
76
+ "description": "Optional compact task memory to preserve progress across long tool loops.",
77
+ "properties": TASK_MEMORY_PROPERTIES,
78
+ "additionalProperties": False,
79
+ },
80
+ },
81
+ "required": ["items"],
82
+ },
83
+ }
84
+
85
+
86
+ # Dummy handler - the real one is created by TodoManager
87
+ def todo(items, memory=None):
88
+ """Dummy todo handler - should not be called directly."""
89
+ raise RuntimeError("Todo tool handler should be created by TodoManager")
tools/verify.py ADDED
@@ -0,0 +1,107 @@
1
+ """Verification tool for common code-agent checks."""
2
+
3
+ import shlex
4
+ import subprocess
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from .read_file import safe_path, workspace_for
9
+
10
+ MAX_OUTPUT_CHARS = 50_000
11
+ VERIFY_TIMEOUT_SECONDS = 300
12
+
13
+
14
+ def _target_args(target: str, workdir: Path | str | None = None) -> list[str]:
15
+ if not target:
16
+ return []
17
+ path_part = target.split("::", 1)[0]
18
+ if path_part:
19
+ safe_path(path_part, workdir)
20
+ return [target]
21
+
22
+
23
+ def _has_file(workdir: Path | str | None, *names: str) -> bool:
24
+ workspace = workspace_for(workdir)
25
+ return any((workspace.root / name).exists() for name in names)
26
+
27
+
28
+ def _pyproject_contains(marker: str, workdir: Path | str | None = None) -> bool:
29
+ pyproject = workspace_for(workdir).root / "pyproject.toml"
30
+ return pyproject.exists() and marker in pyproject.read_text()
31
+
32
+
33
+ def _command_for(kind: str, target: str, workdir: Path | str | None = None) -> list[str] | None:
34
+ extra = _target_args(target, workdir)
35
+ if kind in {"all", "tests"}:
36
+ return ["pytest", *extra]
37
+ if kind == "lint":
38
+ if _has_file(workdir, "ruff.toml", ".ruff.toml") or _pyproject_contains("[tool.ruff", workdir):
39
+ return ["ruff", "check", *(extra or ["."])]
40
+ return None
41
+ if kind == "typecheck":
42
+ if _has_file(workdir, "mypy.ini", ".mypy.ini") or _pyproject_contains("[tool.mypy", workdir):
43
+ return ["mypy", *(extra or ["."])]
44
+ if _has_file(workdir, "pyrightconfig.json"):
45
+ return ["pyright", *(extra or ["."])]
46
+ return None
47
+ raise ValueError(f"unsupported verify kind: {kind}")
48
+
49
+
50
+ def verify(kind: str = "all", target: str = "", workdir: Path | str | None = None) -> str:
51
+ """Run a verification check and return command output."""
52
+ try:
53
+ workspace = workspace_for(workdir)
54
+ kind = (kind or "all").strip().lower()
55
+ command = _command_for(kind, target or "", workspace.root)
56
+ if command is None:
57
+ return f"No {kind} configuration found."
58
+
59
+ started = time.monotonic()
60
+ result = subprocess.run(
61
+ command,
62
+ cwd=workspace.root,
63
+ capture_output=True,
64
+ text=True,
65
+ errors="backslashreplace",
66
+ timeout=VERIFY_TIMEOUT_SECONDS,
67
+ )
68
+ elapsed = time.monotonic() - started
69
+ output = (result.stdout + result.stderr).strip()
70
+ rendered_command = " ".join(shlex.quote(part) for part in command)
71
+ status = "passed" if result.returncode == 0 else "failed"
72
+ return (
73
+ f"verify {status}: {rendered_command}\n"
74
+ f"exit_code: {result.returncode}\n"
75
+ f"duration: {elapsed:.1f}s\n"
76
+ f"output:\n{output or '(no output)'}"
77
+ )[:MAX_OUTPUT_CHARS]
78
+ except subprocess.TimeoutExpired:
79
+ return f"Error: Timeout ({VERIFY_TIMEOUT_SECONDS}s)"
80
+ except Exception as exc:
81
+ return f"Error: {exc}"
82
+
83
+
84
+ verify_tool = {
85
+ "name": "verify",
86
+ "description": "Run common verification checks such as tests, lint, or typecheck.",
87
+ "execution": {
88
+ "side_effects": "process",
89
+ "concurrency": "serial",
90
+ "timeout_seconds": VERIFY_TIMEOUT_SECONDS,
91
+ },
92
+ "input_schema": {
93
+ "type": "object",
94
+ "properties": {
95
+ "kind": {
96
+ "type": "string",
97
+ "enum": ["all", "tests", "lint", "typecheck"],
98
+ "description": "Verification type to run. Defaults to all.",
99
+ },
100
+ "target": {
101
+ "type": "string",
102
+ "description": "Optional workspace-relative target, such as a test file.",
103
+ },
104
+ },
105
+ "required": [],
106
+ },
107
+ }
tools/web_search.py ADDED
@@ -0,0 +1,250 @@
1
+ """No-key web search tool.
2
+
3
+ The default provider uses DuckDuckGo's HTML endpoint as a best-effort,
4
+ keyless search backend. This tool intentionally returns search results only;
5
+ full page fetching/extraction should be implemented as a separate tool.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from dataclasses import dataclass
16
+ from html import unescape
17
+ from html.parser import HTMLParser
18
+
19
+ MAX_RESULTS = 10
20
+ MAX_OUTPUT_CHARS = 20_000
21
+ DEFAULT_TIMEOUT_SECONDS = 15
22
+ DUCKDUCKGO_HTML_URL = "https://html.duckduckgo.com/html/"
23
+ DUCKDUCKGO_LITE_URL = "https://lite.duckduckgo.com/lite/"
24
+ USER_AGENT = "yoyoagent-web-search/0.1 (+https://github.com/yoyofx/yoyoagent)"
25
+
26
+
27
+ @dataclass
28
+ class SearchResult:
29
+ title: str
30
+ url: str
31
+ snippet: str = ""
32
+
33
+
34
+ class DuckDuckGoHTMLParser(HTMLParser):
35
+ """Small parser for DuckDuckGo HTML search results."""
36
+
37
+ def __init__(self) -> None:
38
+ super().__init__(convert_charrefs=True)
39
+ self.results: list[SearchResult] = []
40
+ self._current: dict[str, str] | None = None
41
+ self._capture: str | None = None
42
+ self._parts: list[str] = []
43
+
44
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
45
+ attr = {key: value or "" for key, value in attrs}
46
+ classes = set(attr.get("class", "").split())
47
+
48
+ if tag == "a" and ("result__a" in classes or "result-link" in classes):
49
+ self._finish_result()
50
+ self._current = {"title": "", "url": _clean_duckduckgo_url(attr.get("href", "")), "snippet": ""}
51
+ self._capture = "title"
52
+ self._parts = []
53
+ return
54
+
55
+ if self._current is not None and (
56
+ "result__snippet" in classes or "result-snippet" in classes
57
+ ):
58
+ self._capture = "snippet"
59
+ self._parts = []
60
+
61
+ def handle_data(self, data: str) -> None:
62
+ if self._capture:
63
+ self._parts.append(data)
64
+
65
+ def handle_endtag(self, tag: str) -> None:
66
+ if self._current is None or self._capture is None:
67
+ return
68
+ if self._capture == "title" and tag == "a":
69
+ self._current["title"] = _normalize_space(" ".join(self._parts))
70
+ self._capture = None
71
+ self._parts = []
72
+ elif self._capture == "snippet":
73
+ self._current["snippet"] = _normalize_space(" ".join(self._parts))
74
+ self._capture = None
75
+ self._parts = []
76
+
77
+ def close(self) -> None:
78
+ super().close()
79
+ self._finish_result()
80
+
81
+ def _finish_result(self) -> None:
82
+ if not self._current:
83
+ return
84
+ title = self._current.get("title", "").strip()
85
+ url = self._current.get("url", "").strip()
86
+ if title and url:
87
+ self.results.append(
88
+ SearchResult(
89
+ title=title,
90
+ url=url,
91
+ snippet=self._current.get("snippet", "").strip(),
92
+ )
93
+ )
94
+ self._current = None
95
+ self._capture = None
96
+ self._parts = []
97
+
98
+
99
+ def _normalize_space(text: str) -> str:
100
+ return re.sub(r"\s+", " ", unescape(text)).strip()
101
+
102
+
103
+ def _clean_duckduckgo_url(url: str) -> str:
104
+ url = unescape(url).strip()
105
+ parsed = urllib.parse.urlparse(url)
106
+ query = urllib.parse.parse_qs(parsed.query)
107
+ if "uddg" in query and query["uddg"]:
108
+ return query["uddg"][0]
109
+ return url
110
+
111
+
112
+ def _fetch_url(url: str, *, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> str:
113
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
114
+ with urllib.request.urlopen(request, timeout=timeout) as response:
115
+ charset = response.headers.get_content_charset() or "utf-8"
116
+ return response.read().decode(charset, errors="replace")
117
+
118
+
119
+ def _parse_duckduckgo_html(html: str, max_results: int) -> list[SearchResult]:
120
+ parser = DuckDuckGoHTMLParser()
121
+ parser.feed(html)
122
+ parser.close()
123
+ return parser.results[:max_results]
124
+
125
+
126
+ def _search_duckduckgo(query: str, max_results: int, timeout: int) -> list[SearchResult]:
127
+ params = urllib.parse.urlencode({"q": query})
128
+ html = _fetch_url(f"{DUCKDUCKGO_HTML_URL}?{params}", timeout=timeout)
129
+ results = _parse_duckduckgo_html(html, max_results)
130
+ if results:
131
+ return results
132
+ lite_html = _fetch_url(f"{DUCKDUCKGO_LITE_URL}?{params}", timeout=timeout)
133
+ return _parse_duckduckgo_html(lite_html, max_results)
134
+
135
+
136
+ def _search_searxng(
137
+ query: str,
138
+ max_results: int,
139
+ timeout: int,
140
+ searxng_base_url: str | None,
141
+ ) -> list[SearchResult]:
142
+ if not searxng_base_url:
143
+ raise ValueError("searxng_base_url is required when provider='searxng'")
144
+ base_url = searxng_base_url.rstrip("/")
145
+ params = urllib.parse.urlencode({"q": query, "format": "json"})
146
+ payload = _fetch_url(f"{base_url}/search?{params}", timeout=timeout)
147
+ data = json.loads(payload)
148
+ results = []
149
+ for item in data.get("results", [])[:max_results]:
150
+ title = _normalize_space(str(item.get("title", "")))
151
+ url = str(item.get("url", "")).strip()
152
+ snippet = _normalize_space(str(item.get("content", item.get("snippet", ""))))
153
+ if title and url:
154
+ results.append(SearchResult(title=title, url=url, snippet=snippet))
155
+ return results
156
+
157
+
158
+ def _format_results(provider: str, query: str, results: list[SearchResult]) -> str:
159
+ if not results:
160
+ return f"No results found for {query!r} via {provider}."
161
+
162
+ lines = [f"web_search provider={provider} query={query!r} results={len(results)}"]
163
+ for index, result in enumerate(results, start=1):
164
+ lines.append(f"\n{index}. {result.title}")
165
+ lines.append(f" URL: {result.url}")
166
+ if result.snippet:
167
+ lines.append(f" Snippet: {result.snippet}")
168
+ return "\n".join(lines)[:MAX_OUTPUT_CHARS]
169
+
170
+
171
+ def web_search(
172
+ query: str,
173
+ max_results: int = 5,
174
+ provider: str = "auto",
175
+ searxng_base_url: str | None = None,
176
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
177
+ ) -> str:
178
+ """Search the web using a no-key provider when possible."""
179
+ try:
180
+ query = query.strip()
181
+ if not query:
182
+ return "Error: query is required"
183
+
184
+ max_results = max(1, min(int(max_results), MAX_RESULTS))
185
+ timeout = max(1, min(int(timeout_seconds), DEFAULT_TIMEOUT_SECONDS))
186
+ provider = (provider or "auto").strip().lower()
187
+
188
+ if provider == "auto":
189
+ provider = "searxng" if searxng_base_url else "duckduckgo"
190
+
191
+ if provider in {"duckduckgo", "ddg"}:
192
+ provider_name = "duckduckgo"
193
+ results = _search_duckduckgo(query, max_results, timeout)
194
+ elif provider == "searxng":
195
+ provider_name = "searxng"
196
+ results = _search_searxng(query, max_results, timeout, searxng_base_url)
197
+ else:
198
+ return "Error: provider must be one of auto, duckduckgo, ddg, searxng"
199
+
200
+ return _format_results(provider_name, query, results)
201
+ except urllib.error.URLError as exc:
202
+ return f"Error: network request failed: {exc}"
203
+ except TimeoutError:
204
+ return "Error: network request timed out"
205
+ except json.JSONDecodeError as exc:
206
+ return f"Error: invalid provider response JSON: {exc}"
207
+ except ValueError as exc:
208
+ return f"Error: {exc}"
209
+ except Exception as exc:
210
+ return f"Error: {exc}"
211
+
212
+
213
+ web_search_tool = {
214
+ "name": "web_search",
215
+ "description": (
216
+ "Search online resources using keyless providers when possible. "
217
+ "Default is DuckDuckGo HTML/Lite best-effort search; optional SearXNG requires a base URL."
218
+ ),
219
+ "execution": {
220
+ "side_effects": "read_only",
221
+ "concurrency": "safe",
222
+ "timeout_seconds": 20,
223
+ },
224
+ "input_schema": {
225
+ "type": "object",
226
+ "properties": {
227
+ "query": {
228
+ "type": "string",
229
+ "description": "Search query.",
230
+ },
231
+ "max_results": {
232
+ "type": "integer",
233
+ "description": f"Maximum number of results, capped at {MAX_RESULTS}. Defaults to 5.",
234
+ },
235
+ "provider": {
236
+ "type": "string",
237
+ "description": "Search provider: auto, duckduckgo, ddg, or searxng. Defaults to auto.",
238
+ },
239
+ "searxng_base_url": {
240
+ "type": "string",
241
+ "description": "Optional SearXNG instance base URL used when provider is searxng or auto.",
242
+ },
243
+ "timeout_seconds": {
244
+ "type": "integer",
245
+ "description": f"Network timeout in seconds, capped at {DEFAULT_TIMEOUT_SECONDS}.",
246
+ },
247
+ },
248
+ "required": ["query"],
249
+ },
250
+ }
tools/workspace.py ADDED
@@ -0,0 +1,36 @@
1
+ """Workspace path helpers shared by tools and runtime."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Workspace:
9
+ """Resolved workspace root with safe path helpers."""
10
+
11
+ root: Path
12
+
13
+ def __post_init__(self) -> None:
14
+ resolved = self.root.expanduser().resolve()
15
+ if not resolved.exists():
16
+ raise ValueError(f"workspace does not exist: {resolved}")
17
+ if not resolved.is_dir():
18
+ raise ValueError(f"workspace is not a directory: {resolved}")
19
+ object.__setattr__(self, "root", resolved)
20
+
21
+ def safe_path(self, path: str | Path) -> Path:
22
+ """Return an absolute path constrained within the workspace."""
23
+ raw_path = Path(path).expanduser()
24
+ resolved = raw_path.resolve() if raw_path.is_absolute() else (self.root / raw_path).resolve()
25
+ if not resolved.is_relative_to(self.root):
26
+ raise ValueError(f"Path escapes workspace: {path}")
27
+ return resolved
28
+
29
+ def relative_path(self, path: str | Path) -> str:
30
+ """Return a workspace-relative path after safety checks."""
31
+ return str(self.safe_path(path).relative_to(self.root))
32
+
33
+
34
+ def resolve_workspace(path: str | Path | None = None) -> Workspace:
35
+ """Resolve a user-provided workspace or default to the current directory."""
36
+ return Workspace(Path(path).expanduser() if path is not None else Path.cwd())
@@ -0,0 +1,60 @@
1
+ """Workspace state inspection tool."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from .read_file import workspace_for
7
+
8
+ MAX_OUTPUT_CHARS = 20_000
9
+
10
+
11
+ def _run_git(args: list[str], workdir: Path | str | None = None) -> tuple[int, str]:
12
+ workspace = workspace_for(workdir)
13
+ result = subprocess.run(
14
+ ["git", *args],
15
+ cwd=workspace.root,
16
+ capture_output=True,
17
+ text=True,
18
+ errors="backslashreplace",
19
+ timeout=30,
20
+ )
21
+ return result.returncode, (result.stdout + result.stderr).strip()
22
+
23
+
24
+ def workspace_state(workdir: Path | str | None = None) -> str:
25
+ """Return branch and working tree status."""
26
+ try:
27
+ code, branch = _run_git(["branch", "--show-current"], workdir)
28
+ if code != 0:
29
+ return f"Error: {branch or 'not a git repository'}"
30
+
31
+ _, status = _run_git(["status", "--short"], workdir)
32
+ lines = [line for line in status.splitlines() if line.strip()]
33
+ changed = len(lines)
34
+ status_text = "\n".join(lines) if lines else "clean"
35
+
36
+ return (
37
+ f"branch: {branch or '(detached)'}\n"
38
+ f"changed_files: {changed}\n"
39
+ f"status:\n{status_text}"
40
+ )[:MAX_OUTPUT_CHARS]
41
+ except subprocess.TimeoutExpired:
42
+ return "Error: Timeout (30s)"
43
+ except Exception as exc:
44
+ return f"Error: {exc}"
45
+
46
+
47
+ workspace_state_tool = {
48
+ "name": "workspace_state",
49
+ "description": "Inspect the current git branch and working tree status.",
50
+ "execution": {
51
+ "side_effects": "read_only",
52
+ "concurrency": "safe",
53
+ "timeout_seconds": 30,
54
+ },
55
+ "input_schema": {
56
+ "type": "object",
57
+ "properties": {},
58
+ "required": [],
59
+ },
60
+ }
tools/write_file.py ADDED
@@ -0,0 +1,88 @@
1
+ """Write file tool."""
2
+
3
+ from difflib import unified_diff
4
+ from pathlib import Path
5
+
6
+ from .diff_utils import format_diff_result
7
+ from .read_file import safe_path
8
+ from .safety import approval_required
9
+
10
+
11
+ def _apply_patch_required_message(path: str) -> str:
12
+ return (
13
+ f"Code workflow guard blocked write_file for existing file: {path}\n\n"
14
+ "Use apply_patch with path + old_text + new_text, or a unified diff, "
15
+ "for existing file edits. write_file is only allowed for brand-new files."
16
+ )
17
+
18
+
19
+ def write_file(
20
+ path: str,
21
+ content: str,
22
+ approved: bool = False,
23
+ workdir: Path | str | None = None,
24
+ ) -> str:
25
+ """Write content to file."""
26
+ try:
27
+ fp = safe_path(path, workdir)
28
+ if fp.exists():
29
+ return _apply_patch_required_message(path)
30
+ if not approved:
31
+ return approval_required(
32
+ action="create_file",
33
+ path=path,
34
+ reason="write_file creates a new file and requires user approval before writing.",
35
+ risk="Creating files changes the workspace and may add unwanted artifacts.",
36
+ )
37
+ fp.parent.mkdir(parents=True, exist_ok=True)
38
+ fp.write_text(content)
39
+ return format_diff_result(f"Wrote {len(content)} bytes to {path}", [path], workdir=workdir)
40
+ except Exception as e:
41
+ return f"Error: {e}"
42
+
43
+
44
+ def preview_write_file_diff(path: str, content: str, workdir: Path | str | None = None) -> str:
45
+ """Return the diff that write_file would create without writing."""
46
+ try:
47
+ fp = safe_path(path, workdir)
48
+ if fp.exists():
49
+ return ""
50
+ lines = "\n".join(
51
+ unified_diff(
52
+ [],
53
+ content.splitlines(),
54
+ fromfile="/dev/null",
55
+ tofile=f"b/{path}",
56
+ lineterm="",
57
+ )
58
+ )
59
+ return lines
60
+ except Exception:
61
+ return ""
62
+
63
+
64
+ write_file_tool = {
65
+ "name": "write_file",
66
+ "description": (
67
+ "Create a brand-new file or generated artifact. Do not use this for existing "
68
+ "file edits; use apply_patch for existing files so the diff is reviewable. "
69
+ "Requires approved=true after explicit user approval."
70
+ ),
71
+ "execution": {
72
+ "side_effects": "workspace_write",
73
+ "concurrency": "serial",
74
+ "timeout_seconds": 60,
75
+ },
76
+ "input_schema": {
77
+ "type": "object",
78
+ "properties": {
79
+ "path": {"type": "string"},
80
+ "content": {"type": "string"},
81
+ "approved": {
82
+ "type": "boolean",
83
+ "description": "Set true only after the user explicitly approves this file creation.",
84
+ },
85
+ },
86
+ "required": ["path", "content"],
87
+ },
88
+ }