kiwimatecoder 0.1.0__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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,108 @@
1
+ """Custom prompt templates runnable as REPL slash commands.
2
+
3
+ Users drop Markdown files named after the command into
4
+ ``~/.kiwimatecoder/commands/`` or ``<workspace>/.kiwimatecoder/commands/``.
5
+ The file body is the prompt; ``$ARGUMENTS`` is replaced with whatever the user
6
+ typed after the command name. A workspace template wins over a user-level
7
+ template with the same name.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from kiwimatecoder import config
17
+
18
+ WORKSPACE_COMMANDS_DIR = Path(".kiwimatecoder") / "commands"
19
+ USER_COMMANDS_DIR_NAME = "commands"
20
+ MAX_TEMPLATE_BYTES = 16 * 1024
21
+ MAX_NAME_LENGTH = 40
22
+ ARGUMENTS_TOKEN = "$ARGUMENTS"
23
+
24
+ _NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9_-]*\Z")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TemplateInfo:
29
+ """One discovered prompt template."""
30
+
31
+ name: str
32
+ path: Path
33
+ description: str
34
+ body: str
35
+
36
+
37
+ def _valid_name(name: str) -> bool:
38
+ return bool(name) and len(name) <= MAX_NAME_LENGTH and bool(_NAME_PATTERN.match(name))
39
+
40
+
41
+ def _first_line(text: str, fallback: str) -> str:
42
+ for raw_line in text.splitlines():
43
+ line = raw_line.strip().lstrip("#").strip()
44
+ if line:
45
+ return line
46
+ return fallback
47
+
48
+
49
+ def _load_template(path: Path) -> TemplateInfo | None:
50
+ name = path.stem.strip().lower()
51
+ if not _valid_name(name):
52
+ return None
53
+ try:
54
+ raw = path.read_bytes()
55
+ except OSError:
56
+ return None
57
+ if b"\x00" in raw[:1024]:
58
+ return None
59
+ body = raw[:MAX_TEMPLATE_BYTES].decode("utf-8", "replace")
60
+ return TemplateInfo(
61
+ name=name,
62
+ path=path,
63
+ description=_first_line(body, name),
64
+ body=body,
65
+ )
66
+
67
+
68
+ def _template_dirs(workspace_root: Path) -> list[Path]:
69
+ """Workspace first so it wins name collisions with user templates."""
70
+ dirs = [workspace_root / WORKSPACE_COMMANDS_DIR]
71
+ try:
72
+ dirs.append(config.ensure_config_dir() / USER_COMMANDS_DIR_NAME)
73
+ except OSError:
74
+ # An unwritable home directory just means no user-level templates.
75
+ pass
76
+ return dirs
77
+
78
+
79
+ def discover_templates(workspace_root: Path) -> dict[str, TemplateInfo]:
80
+ """Return every valid template keyed by command name, sorted by name."""
81
+ found: dict[str, TemplateInfo] = {}
82
+ for directory in _template_dirs(workspace_root):
83
+ if not directory.is_dir():
84
+ continue
85
+ for path in sorted(directory.glob("*.md")):
86
+ template = _load_template(path)
87
+ if template is None:
88
+ continue
89
+ found.setdefault(template.name, template)
90
+ return dict(sorted(found.items()))
91
+
92
+
93
+ def find_template(name: str, workspace_root: Path) -> TemplateInfo | None:
94
+ """Look up a template by command name; None when unknown or invalid."""
95
+ key = str(name).strip().lower()
96
+ if not key:
97
+ return None
98
+ return discover_templates(workspace_root).get(key)
99
+
100
+
101
+ def render_template(body: str, arguments: str) -> str:
102
+ """Substitute ``$ARGUMENTS``; append the arguments when the body lacks it."""
103
+ text = arguments.strip()
104
+ if ARGUMENTS_TOKEN in body:
105
+ return body.replace(ARGUMENTS_TOKEN, text)
106
+ if text:
107
+ return f"{body}\n\nArguments: {text}"
108
+ return body
@@ -0,0 +1,190 @@
1
+ """Tool registry: schema export, dispatch, and approval previews.
2
+
3
+ A tool is "advertised" to the model via its JSON schema. In PLAN mode only
4
+ read-only tools are advertised (and the permission gate blocks the rest as a
5
+ second line of defense).
6
+
7
+ The mapping lives in :class:`~kiwimatecoder.tools.registry.ToolRegistry`, which
8
+ also records each tool's source (``"builtin"`` or a plugin label). The module
9
+ level ``TOOLS`` name is that registry and remains dict-compatible.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Any, Callable
15
+
16
+ from kiwimatecoder.tools.ask import ask_user_tool
17
+ from kiwimatecoder.tools.base import FunctionTool, ToolResult
18
+ from kiwimatecoder.tools.browser import browser_tool
19
+ from kiwimatecoder.tools.browser import preview as _browser_preview
20
+ from kiwimatecoder.tools.edit_file import edit_file_tool
21
+ from kiwimatecoder.tools.edit_file import preview as _edit_preview
22
+ from kiwimatecoder.tools.forge import forge_tool, forge_write_tool
23
+ from kiwimatecoder.tools.forge import preview as _forge_write_preview
24
+ from kiwimatecoder.tools.git import git_tool, git_write_tool
25
+ from kiwimatecoder.tools.git import preview as _git_write_preview
26
+ from kiwimatecoder.tools.image import (
27
+ generate_image_tool,
28
+ generate_video_tool,
29
+ view_image_tool,
30
+ )
31
+ from kiwimatecoder.tools.image import generate_image_preview as _media_preview
32
+ from kiwimatecoder.tools.image import generate_video_preview as _video_preview
33
+ from kiwimatecoder.tools.io import (
34
+ http_get_tool,
35
+ http_request_tool,
36
+ read_clipboard_tool,
37
+ write_clipboard_tool,
38
+ )
39
+ from kiwimatecoder.tools.io import http_request_preview as _http_request_preview
40
+ from kiwimatecoder.tools.io import write_clipboard_preview as _clipboard_write_preview
41
+ from kiwimatecoder.tools.list_dir import list_dir_tool
42
+ from kiwimatecoder.tools.lsp import (
43
+ lsp_definition_tool,
44
+ lsp_diagnostics_tool,
45
+ lsp_references_tool,
46
+ )
47
+ from kiwimatecoder.tools.memory import preview as _remember_preview
48
+ from kiwimatecoder.tools.memory import recall_tool, remember_tool
49
+ from kiwimatecoder.tools.read_file import read_file_tool
50
+ from kiwimatecoder.tools.registry import (
51
+ BUILTIN_SOURCE,
52
+ PLUGIN_SOURCE,
53
+ ToolRegistry,
54
+ )
55
+ from kiwimatecoder.tools.run_bash import preview as _bash_preview
56
+ from kiwimatecoder.tools.run_bash import run_bash_tool
57
+ from kiwimatecoder.tools.search import search_tool
58
+ from kiwimatecoder.tools.selection import select_hunks as select_hunks
59
+ from kiwimatecoder.tools.shell import jobs_preview as _shell_jobs_preview
60
+ from kiwimatecoder.tools.shell import preview as _shell_preview
61
+ from kiwimatecoder.tools.shell import shell_jobs_tool, shell_tool
62
+ from kiwimatecoder.tools.skill import load_skill_tool
63
+ from kiwimatecoder.tools.task import task_tool
64
+ from kiwimatecoder.tools.todo import update_todos_tool
65
+ from kiwimatecoder.tools.web import web_fetch_tool, web_search_tool
66
+ from kiwimatecoder.tools.write_file import preview as _write_preview
67
+ from kiwimatecoder.tools.write_file import write_file_tool
68
+
69
+ if TYPE_CHECKING:
70
+ from kiwimatecoder.session import Session
71
+
72
+ _ALL_TOOLS: list[FunctionTool] = [
73
+ read_file_tool,
74
+ view_image_tool,
75
+ generate_image_tool,
76
+ generate_video_tool,
77
+ list_dir_tool,
78
+ search_tool,
79
+ load_skill_tool,
80
+ write_file_tool,
81
+ edit_file_tool,
82
+ run_bash_tool,
83
+ shell_tool,
84
+ shell_jobs_tool,
85
+ update_todos_tool,
86
+ task_tool,
87
+ ask_user_tool,
88
+ web_fetch_tool,
89
+ web_search_tool,
90
+ git_tool,
91
+ git_write_tool,
92
+ forge_tool,
93
+ forge_write_tool,
94
+ remember_tool,
95
+ recall_tool,
96
+ read_clipboard_tool,
97
+ write_clipboard_tool,
98
+ http_get_tool,
99
+ http_request_tool,
100
+ lsp_diagnostics_tool,
101
+ lsp_definition_tool,
102
+ lsp_references_tool,
103
+ browser_tool,
104
+ ]
105
+
106
+ TOOLS = ToolRegistry()
107
+ for _tool in _ALL_TOOLS:
108
+ TOOLS.register(_tool, source=BUILTIN_SOURCE)
109
+
110
+ # Preview functions used by the permission gate to render what an action will do.
111
+ _PREVIEWS: dict[str, Callable[[dict[str, Any], Any], str]] = {
112
+ "write_file": _write_preview,
113
+ "edit_file": _edit_preview,
114
+ "run_bash": _bash_preview,
115
+ "shell": _shell_preview,
116
+ "shell_jobs": _shell_jobs_preview,
117
+ "git_write": _git_write_preview,
118
+ "forge_write": _forge_write_preview,
119
+ "remember": _remember_preview,
120
+ "write_clipboard": _clipboard_write_preview,
121
+ "http_request": _http_request_preview,
122
+ "browser": _browser_preview,
123
+ "generate_image": _media_preview,
124
+ "generate_video": _video_preview,
125
+ }
126
+
127
+
128
+ def register_tool(tool: FunctionTool, source: str = PLUGIN_SOURCE) -> FunctionTool:
129
+ """Register a tool dynamically and return it.
130
+
131
+ Raises ``ValueError`` when the name is already registered; pass through
132
+ ``TOOLS.register(tool, replace=True)`` to deliberately replace one.
133
+ """
134
+ return TOOLS.register(tool, source=source)
135
+
136
+
137
+ def unregister_tool(name: str, force: bool = False) -> bool:
138
+ """Remove a dynamically registered tool; built-ins need ``force=True``."""
139
+ return TOOLS.unregister(name, force=force)
140
+
141
+
142
+ def tool_sources() -> dict[str, list[str]]:
143
+ """Map each tool source to its sorted tool names."""
144
+ return TOOLS.sources()
145
+
146
+
147
+ def read_only_tools() -> list[FunctionTool]:
148
+ return TOOLS.read_only()
149
+
150
+
151
+ def tool_schemas(read_only: bool = False) -> list[dict[str, Any]]:
152
+ """Return OpenAI tool schemas, optionally restricted to read-only tools."""
153
+ return TOOLS.schemas(read_only=read_only)
154
+
155
+
156
+ def get_tool(name: str) -> FunctionTool | None:
157
+ return TOOLS.get(name)
158
+
159
+
160
+ def preview(name: str, args: dict[str, Any], session: Session) -> str | None:
161
+ """Return a human-readable preview of a tool action, or None if not previewable."""
162
+ fn = _PREVIEWS.get(name)
163
+ return fn(args, session) if fn else None
164
+
165
+
166
+ def dispatch(name: str, args: dict[str, Any], session: Session) -> ToolResult:
167
+ """Execute a tool by name."""
168
+ tool = TOOLS.get(name)
169
+ if tool is None:
170
+ return ToolResult.error(f"Unknown tool: {name}")
171
+ return tool.execute(args, session)
172
+
173
+
174
+ __all__ = [
175
+ "BUILTIN_SOURCE",
176
+ "PLUGIN_SOURCE",
177
+ "TOOLS",
178
+ "FunctionTool",
179
+ "ToolRegistry",
180
+ "ToolResult",
181
+ "dispatch",
182
+ "get_tool",
183
+ "preview",
184
+ "read_only_tools",
185
+ "register_tool",
186
+ "select_hunks",
187
+ "tool_schemas",
188
+ "tool_sources",
189
+ "unregister_tool",
190
+ ]
@@ -0,0 +1,62 @@
1
+ """ask_user tool: ask the user a clarifying question mid-turn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from kiwimatecoder.session import Session
8
+ from kiwimatecoder.tools.base import FunctionTool, ToolResult
9
+
10
+
11
+ def _ask_user(args: dict[str, Any], session: Session) -> ToolResult:
12
+ question = str(args.get("question") or "").strip()
13
+ if not question:
14
+ return ToolResult.error("'question' is required")
15
+
16
+ raw_options = args.get("options")
17
+ options = (
18
+ [str(option).strip() for option in raw_options if str(option).strip()]
19
+ if isinstance(raw_options, list)
20
+ else []
21
+ )
22
+
23
+ callback = getattr(session, "ask_user", None)
24
+ if callback is None:
25
+ return ToolResult.error(
26
+ "No interactive user is available to answer; make a reasonable "
27
+ "assumption and state it."
28
+ )
29
+ try:
30
+ answer = callback(question, options)
31
+ except (EOFError, KeyboardInterrupt):
32
+ return ToolResult.error("The user did not answer.")
33
+ answer = str(answer or "").strip()
34
+ if not answer:
35
+ return ToolResult.error("The user gave an empty answer.")
36
+ return ToolResult(content=f"User answer: {answer}")
37
+
38
+
39
+ ask_user_tool = FunctionTool(
40
+ name="ask_user",
41
+ description=(
42
+ "Ask the user a clarifying question when a decision cannot be inferred "
43
+ "from the workspace. Provide 2-4 concrete options when possible. Use "
44
+ "sparingly: prefer reading the relevant files first."
45
+ ),
46
+ parameters={
47
+ "type": "object",
48
+ "properties": {
49
+ "question": {
50
+ "type": "string",
51
+ "description": "The question to ask.",
52
+ },
53
+ "options": {
54
+ "type": "array",
55
+ "description": "Optional suggested answers.",
56
+ "items": {"type": "string"},
57
+ },
58
+ },
59
+ "required": ["question"],
60
+ },
61
+ func=_ask_user,
62
+ )
@@ -0,0 +1,69 @@
1
+ """Core types shared by all tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Callable, Protocol
8
+
9
+
10
+ class SessionProtocol(Protocol):
11
+ """Protocol representing the session interface required by tools."""
12
+
13
+ workspace_root: Path
14
+
15
+ def record_touched(self, path: str) -> None: ...
16
+
17
+
18
+ @dataclass
19
+ class ToolResult:
20
+ """The outcome of running a tool."""
21
+
22
+ content: str
23
+ ok: bool = True
24
+
25
+ @classmethod
26
+ def error(cls, message: str) -> "ToolResult":
27
+ return cls(content=f"Error: {message}", ok=False)
28
+
29
+
30
+ class Tool(Protocol):
31
+ """Protocol every tool implements."""
32
+
33
+ name: str
34
+ description: str
35
+ parameters: dict[str, Any] # JSON Schema for the function arguments
36
+ writes: bool # mutates the filesystem
37
+ runs: bool # executes commands
38
+
39
+ def execute(self, args: dict[str, Any], session: Any) -> ToolResult: ...
40
+
41
+
42
+ @dataclass
43
+ class FunctionTool:
44
+ """Concrete tool backed by a plain callable."""
45
+
46
+ name: str
47
+ description: str
48
+ parameters: dict[str, Any]
49
+ func: Callable[[dict[str, Any], Any], ToolResult]
50
+ writes: bool = False
51
+ runs: bool = False
52
+
53
+ def execute(self, args: dict[str, Any], session: Any) -> ToolResult:
54
+ return self.func(args, session)
55
+
56
+ def schema(self) -> dict[str, Any]:
57
+ """Return the OpenAI tool/function schema for this tool."""
58
+ return {
59
+ "type": "function",
60
+ "function": {
61
+ "name": self.name,
62
+ "description": self.description,
63
+ "parameters": self.parameters,
64
+ },
65
+ }
66
+
67
+ @property
68
+ def needs_approval(self) -> bool:
69
+ return self.writes or self.runs
@@ -0,0 +1,261 @@
1
+ """browser tool: drive an optional headless Chromium page via Playwright.
2
+
3
+ The tool is approval-gated (``runs=True``) and only advertised while browser
4
+ automation is enabled in config. It never imports Playwright itself; the
5
+ optional driver lives in :mod:`kiwimatecoder.browser`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from kiwimatecoder import browser, config, images, redaction
14
+ from kiwimatecoder import web as web_module
15
+ from kiwimatecoder.session import Session
16
+ from kiwimatecoder.tools.base import FunctionTool, ToolResult
17
+ from kiwimatecoder.tools.paths import display_path
18
+
19
+ ACTIONS = ("open", "text", "screenshot", "click", "type", "evaluate", "close")
20
+
21
+ TEXT_MAX_CHARS = 30_000
22
+ EVALUATE_MAX_CHARS = 10_000
23
+ MIN_TIMEOUT_MS = 1_000
24
+ MAX_TIMEOUT_MS = 120_000
25
+
26
+ DISABLED_MESSAGE = (
27
+ "Browser automation is disabled. Enable it with `/config browser enable on` "
28
+ "(and install the optional extra: pip install 'kiwimatecoder[browser]' && "
29
+ "playwright install chromium)."
30
+ )
31
+ NO_PAGE_MESSAGE = "No page is open. Use action='open' with a URL first."
32
+
33
+
34
+ def _timeout_override(args: dict[str, Any]) -> tuple[int | None, str | None]:
35
+ raw = args.get("timeout_ms")
36
+ if raw is None:
37
+ return None, None
38
+ try:
39
+ value = int(raw)
40
+ except (TypeError, ValueError):
41
+ return None, "'timeout_ms' must be an integer number of milliseconds"
42
+ if not MIN_TIMEOUT_MS <= value <= MAX_TIMEOUT_MS:
43
+ return None, (
44
+ f"'timeout_ms' must be between {MIN_TIMEOUT_MS} and {MAX_TIMEOUT_MS}"
45
+ )
46
+ return value, None
47
+
48
+
49
+ def _validate_url(url: str) -> str:
50
+ try:
51
+ cleaned = web_module.clean_url(url)
52
+ except web_module.WebError as exc:
53
+ raise browser.BrowserError(str(exc)) from exc
54
+ if web_module.is_local_address(cleaned) and not config.get_web()["allow_local"]:
55
+ raise browser.BrowserError(
56
+ "Refusing to open a local or private address. Enable it with "
57
+ "/config web allow-local on (or config web allow-local on)."
58
+ )
59
+ return cleaned
60
+
61
+
62
+ def _required(args: dict[str, Any], key: str) -> tuple[str, str | None]:
63
+ value = str(args.get(key) or "").strip()
64
+ if not value:
65
+ return "", f"'{key}' is required"
66
+ return value, None
67
+
68
+
69
+ def _apply_timeout(driver: browser.Driver, timeout_ms: int) -> None:
70
+ setter = getattr(driver, "set_timeout", None)
71
+ if callable(setter):
72
+ setter(timeout_ms)
73
+
74
+
75
+ def _format_result(value: Any) -> str:
76
+ if isinstance(value, str):
77
+ rendered = value
78
+ else:
79
+ try:
80
+ rendered = json.dumps(value, indent=2, default=str)
81
+ except (TypeError, ValueError):
82
+ rendered = repr(value)
83
+ if len(rendered) > EVALUATE_MAX_CHARS:
84
+ rendered = rendered[:EVALUATE_MAX_CHARS] + (
85
+ f"\n\n[truncated at {EVALUATE_MAX_CHARS} characters]"
86
+ )
87
+ return rendered or "(no result)"
88
+
89
+
90
+ def _screenshot(driver: browser.Driver, session: Session) -> ToolResult:
91
+ path = browser.screenshot_path(session.workspace_root)
92
+ driver.screenshot(path)
93
+ label = display_path(path, session.workspace_root)
94
+
95
+ vision = config.get_vision()
96
+ limit = int(vision["max_images_per_turn"])
97
+ if len(session.pending_images) >= limit:
98
+ return ToolResult(
99
+ content=(
100
+ f"Saved screenshot to {label}; the per-turn image limit "
101
+ f"({limit}) is reached, so it was not attached."
102
+ )
103
+ )
104
+ try:
105
+ entry = images.encode_image(path, int(vision["max_image_bytes"]))
106
+ except (ValueError, OSError) as exc:
107
+ return ToolResult(
108
+ content=f"Saved screenshot to {label} but could not attach it: {exc}"
109
+ )
110
+ session.pending_images.append(entry)
111
+ return ToolResult(content=f"Saved screenshot to {label} and attached it.")
112
+
113
+
114
+ def _browser(args: dict[str, Any], session: Session) -> ToolResult:
115
+ action = str(args.get("action") or "").strip().lower()
116
+ if action not in ACTIONS:
117
+ return ToolResult.error(
118
+ f"Unknown browser action '{action or '?'}'. "
119
+ f"Choose: {', '.join(ACTIONS)}."
120
+ )
121
+ settings = config.get_browser()
122
+ if not settings["enabled"]:
123
+ return ToolResult.error(DISABLED_MESSAGE)
124
+
125
+ timeout_ms, error = _timeout_override(args)
126
+ if error:
127
+ return ToolResult.error(error)
128
+
129
+ if action == "close":
130
+ browser.reset_driver()
131
+ return ToolResult(content="Browser closed.")
132
+
133
+ if action == "open":
134
+ url = str(args.get("url") or "").strip()
135
+ if not url:
136
+ return ToolResult.error("'url' is required for action 'open'")
137
+ try:
138
+ cleaned = _validate_url(url)
139
+ driver = browser.get_driver(settings)
140
+ if timeout_ms is not None:
141
+ _apply_timeout(driver, timeout_ms)
142
+ final_url, title = driver.open(cleaned)
143
+ except browser.BrowserError as exc:
144
+ return ToolResult.error(str(exc))
145
+ return ToolResult(
146
+ content=f"Opened {final_url}\nTitle: {title or '(no title)'}"
147
+ )
148
+
149
+ page = browser.current_driver()
150
+ if page is None:
151
+ return ToolResult.error(NO_PAGE_MESSAGE)
152
+
153
+ try:
154
+ if timeout_ms is not None:
155
+ _apply_timeout(page, timeout_ms)
156
+
157
+ if action == "text":
158
+ page_text = page.text()
159
+ if len(page_text) > TEXT_MAX_CHARS:
160
+ page_text = page_text[:TEXT_MAX_CHARS] + (
161
+ f"\n\n[truncated at {TEXT_MAX_CHARS} characters]"
162
+ )
163
+ return ToolResult(content=page_text or "(no visible text)")
164
+
165
+ if action == "screenshot":
166
+ return _screenshot(page, session)
167
+
168
+ if action == "click":
169
+ selector, error = _required(args, "selector")
170
+ if error:
171
+ return ToolResult.error(error)
172
+ page.click(selector)
173
+ return ToolResult(content=f"Clicked {selector}.")
174
+
175
+ if action == "type":
176
+ selector, error = _required(args, "selector")
177
+ if error:
178
+ return ToolResult.error(error)
179
+ text = str(args.get("text") or "")
180
+ if not text:
181
+ return ToolResult.error("'text' is required for action 'type'")
182
+ page.type(selector, text)
183
+ return ToolResult(content=f"Typed into {selector}.")
184
+
185
+ if action == "evaluate":
186
+ script, error = _required(args, "script")
187
+ if error:
188
+ return ToolResult.error(error)
189
+ return ToolResult(content=_format_result(page.evaluate(script)))
190
+ except browser.BrowserError as exc:
191
+ return ToolResult.error(str(exc))
192
+
193
+ return ToolResult.error(f"Unsupported browser action '{action}'.")
194
+
195
+
196
+ def preview(args: dict[str, Any], session: Session) -> str:
197
+ """Render the approval preview: ``browser <action> <target>``."""
198
+ del session
199
+ action = str(args.get("action") or "?")
200
+ target = (
201
+ args.get("url")
202
+ or args.get("selector")
203
+ or args.get("script")
204
+ or args.get("text")
205
+ or ""
206
+ )
207
+ cleaned = redaction.redact(str(target))
208
+ if len(cleaned) > 80:
209
+ cleaned = cleaned[:77] + "..."
210
+ return f"browser {action} {cleaned}".rstrip()
211
+
212
+
213
+ browser_tool = FunctionTool(
214
+ name="browser",
215
+ description=(
216
+ "Drive a headless Chromium page when browser automation is enabled. "
217
+ "Start with action='open' and a URL, then read the page with 'text', "
218
+ "capture it with 'screenshot' (the image is sent to you), interact "
219
+ "with 'click'/'type', run JavaScript with 'evaluate', or finish with "
220
+ "'close'. Local and private addresses are blocked unless enabled in "
221
+ "/config web. Requires the optional Playwright extra."
222
+ ),
223
+ parameters={
224
+ "type": "object",
225
+ "properties": {
226
+ "action": {
227
+ "type": "string",
228
+ "enum": list(ACTIONS),
229
+ "description": "Browser action to perform.",
230
+ },
231
+ "url": {
232
+ "type": "string",
233
+ "description": "Absolute http(s) URL for action 'open'.",
234
+ },
235
+ "selector": {
236
+ "type": "string",
237
+ "description": "CSS selector for 'click' or 'type'.",
238
+ },
239
+ "text": {
240
+ "type": "string",
241
+ "description": "Text to enter for action 'type'.",
242
+ },
243
+ "script": {
244
+ "type": "string",
245
+ "description": "JavaScript expression for action 'evaluate'.",
246
+ },
247
+ "timeout_ms": {
248
+ "type": "integer",
249
+ "description": (
250
+ "Optional per-action timeout in milliseconds "
251
+ f"({MIN_TIMEOUT_MS}-{MAX_TIMEOUT_MS}); defaults to the "
252
+ "configured browser timeout."
253
+ ),
254
+ },
255
+ },
256
+ "required": ["action"],
257
+ },
258
+ func=_browser,
259
+ writes=False,
260
+ runs=True,
261
+ )