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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/forge.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Forge integration through official CLIs (no API tokens in-process).
|
|
2
|
+
|
|
3
|
+
GitHub work goes through ``gh`` and GitLab through ``glab``; the user's CLI
|
|
4
|
+
login provides credentials, so no token is read or stored by KiwiMateCoder.
|
|
5
|
+
Only create/list/view actions are exposed — merge, close, and delete are
|
|
6
|
+
deliberately absent.
|
|
7
|
+
|
|
8
|
+
The argument builders are pure so they can be unit-tested, and they know the
|
|
9
|
+
CLI differences: GitLab calls pull requests "merge requests" and uses
|
|
10
|
+
``--description``/``--target-branch`` instead of ``--body``/``--base``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
MAX_OUTPUT = 30_000
|
|
21
|
+
DEFAULT_TIMEOUT = 60
|
|
22
|
+
FORGE_CLIS = {"github": "gh", "gitlab": "glab"}
|
|
23
|
+
READ_ACTIONS = ("pr_list", "pr_view", "issue_list", "issue_view")
|
|
24
|
+
WRITE_ACTIONS = ("pr_create", "issue_create")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _host_from_remote(remote_url: str) -> str:
|
|
28
|
+
raw = str(remote_url).strip()
|
|
29
|
+
if not raw:
|
|
30
|
+
return ""
|
|
31
|
+
if "://" in raw:
|
|
32
|
+
return (urlsplit(raw).hostname or "").lower().rstrip(".")
|
|
33
|
+
if "@" in raw and ":" in raw:
|
|
34
|
+
return raw.split("@", 1)[1].split(":", 1)[0].strip().lower().rstrip(".")
|
|
35
|
+
return raw.split("/", 1)[0].strip().lower().rstrip(".")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def detect_forge(remote_url: str | None) -> str | None:
|
|
39
|
+
"""Return ``"github"``/``"gitlab"`` for a known host, else ``None``.
|
|
40
|
+
|
|
41
|
+
Recognizes the public hosts (``github.com``, ``gitlab.com``) over https,
|
|
42
|
+
ssh, and scp-style remotes. Self-hosted instances are not guessed.
|
|
43
|
+
"""
|
|
44
|
+
host = _host_from_remote(remote_url or "")
|
|
45
|
+
if host in {"github.com", "www.github.com"}:
|
|
46
|
+
return "github"
|
|
47
|
+
if host in {"gitlab.com", "www.gitlab.com"}:
|
|
48
|
+
return "gitlab"
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def forge_cli(forge: str | None) -> str | None:
|
|
53
|
+
"""The CLI binary for a forge, or ``None`` when unknown."""
|
|
54
|
+
return FORGE_CLIS.get(str(forge or "").lower())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cli_available(command: str) -> bool:
|
|
58
|
+
"""Whether ``command`` is on ``PATH``."""
|
|
59
|
+
return shutil.which(command) is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _pr_verb(forge: str) -> str:
|
|
63
|
+
return "mr" if str(forge).lower() == "gitlab" else "pr"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _body_flag(forge: str) -> str:
|
|
67
|
+
return "--description" if str(forge).lower() == "gitlab" else "--body"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _base_flag(forge: str) -> str:
|
|
71
|
+
return "--target-branch" if str(forge).lower() == "gitlab" else "--base"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pr_list_args(forge: str = "github") -> list[str]:
|
|
75
|
+
return [_pr_verb(forge), "list"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def pr_view_args(number: int, forge: str = "github") -> list[str]:
|
|
79
|
+
return [_pr_verb(forge), "view", str(number)]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pr_create_args(
|
|
83
|
+
title: str,
|
|
84
|
+
body: str = "",
|
|
85
|
+
base: str | None = None,
|
|
86
|
+
draft: bool = False,
|
|
87
|
+
forge: str = "github",
|
|
88
|
+
) -> list[str]:
|
|
89
|
+
args = [
|
|
90
|
+
_pr_verb(forge),
|
|
91
|
+
"create",
|
|
92
|
+
"--title",
|
|
93
|
+
str(title),
|
|
94
|
+
_body_flag(forge),
|
|
95
|
+
str(body),
|
|
96
|
+
]
|
|
97
|
+
if base:
|
|
98
|
+
args.extend([_base_flag(forge), str(base)])
|
|
99
|
+
if draft:
|
|
100
|
+
args.append("--draft")
|
|
101
|
+
return args
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def issue_list_args() -> list[str]:
|
|
105
|
+
return ["issue", "list"]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def issue_view_args(number: int) -> list[str]:
|
|
109
|
+
return ["issue", "view", str(number)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def issue_create_args(title: str, body: str = "", forge: str = "github") -> list[str]:
|
|
113
|
+
return [
|
|
114
|
+
"issue",
|
|
115
|
+
"create",
|
|
116
|
+
"--title",
|
|
117
|
+
str(title),
|
|
118
|
+
_body_flag(forge),
|
|
119
|
+
str(body),
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_write_args(forge: str, args: dict[str, Any]) -> list[str]:
|
|
124
|
+
"""Build the argv for a ``forge_write`` call, raising ``ValueError``."""
|
|
125
|
+
action = str(args.get("action") or "").strip().lower()
|
|
126
|
+
title = str(args.get("title") or "").strip()
|
|
127
|
+
body = str(args.get("body") or "")
|
|
128
|
+
base = str(args["base"]).strip() if args.get("base") else None
|
|
129
|
+
if action == "pr_create":
|
|
130
|
+
if not title:
|
|
131
|
+
raise ValueError("A non-empty 'title' is required for pr_create.")
|
|
132
|
+
return pr_create_args(title, body, base, bool(args.get("draft")), forge=forge)
|
|
133
|
+
if action == "issue_create":
|
|
134
|
+
if not title:
|
|
135
|
+
raise ValueError("A non-empty 'title' is required for issue_create.")
|
|
136
|
+
return issue_create_args(title, body, forge=forge)
|
|
137
|
+
raise ValueError(f"Unknown forge_write action '{action}'.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def current_remote_url(session: Any) -> str | None:
|
|
141
|
+
"""Return the ``origin`` remote URL for the session workspace, if any."""
|
|
142
|
+
from kiwimatecoder import git as git_module
|
|
143
|
+
|
|
144
|
+
code, output = git_module.run_git(["remote", "get-url", "origin"], session)
|
|
145
|
+
if code != 0:
|
|
146
|
+
return None
|
|
147
|
+
text = output.strip()
|
|
148
|
+
return text.splitlines()[0].strip() if text else None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def run_forge(
|
|
152
|
+
cli: str, args: list[str], session: Any, timeout: int = DEFAULT_TIMEOUT
|
|
153
|
+
) -> tuple[int, str]:
|
|
154
|
+
"""Run a forge CLI command in the workspace; never raises, never a shell."""
|
|
155
|
+
command = [cli, *(str(arg) for arg in args)]
|
|
156
|
+
try:
|
|
157
|
+
proc = subprocess.run(
|
|
158
|
+
command,
|
|
159
|
+
cwd=str(session.workspace_root),
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
timeout=timeout,
|
|
163
|
+
check=False,
|
|
164
|
+
)
|
|
165
|
+
except FileNotFoundError:
|
|
166
|
+
return 127, f"{cli} is not installed or not on PATH."
|
|
167
|
+
except subprocess.TimeoutExpired:
|
|
168
|
+
return 124, f"{cli} timed out after {timeout}s."
|
|
169
|
+
output = proc.stdout or ""
|
|
170
|
+
if proc.stderr:
|
|
171
|
+
output = f"{output}\n{proc.stderr}" if output else proc.stderr
|
|
172
|
+
return proc.returncode, output
|
kiwimatecoder/git.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Git helpers: pure argument builders and a subprocess runner.
|
|
2
|
+
|
|
3
|
+
Only safe, non-destructive actions are exposed. ``push``, ``reset``, and
|
|
4
|
+
``clean`` are deliberately absent: mutating the remote or discarding work is
|
|
5
|
+
not something an agent should do through this surface.
|
|
6
|
+
|
|
7
|
+
Argument builders return argv lists so callers pass values as separate
|
|
8
|
+
arguments. That removes any shell-quoting surface: the runner never uses
|
|
9
|
+
``shell=True``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import shlex
|
|
16
|
+
import subprocess
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
DEFAULT_TIMEOUT = 30
|
|
20
|
+
MAX_OUTPUT = 30_000
|
|
21
|
+
LOG_LIMIT_MIN = 1
|
|
22
|
+
LOG_LIMIT_MAX = 100
|
|
23
|
+
WRITE_ACTIONS = ("stage", "unstage", "commit", "checkout_branch")
|
|
24
|
+
|
|
25
|
+
_BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def status_args(*, short: bool = True) -> list[str]:
|
|
29
|
+
"""``git status`` argv, short format by default."""
|
|
30
|
+
return ["status", "--short"] if short else ["status"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def diff_args(
|
|
34
|
+
*, staged: bool = False, path: str | None = None, ref: str | None = None
|
|
35
|
+
) -> list[str]:
|
|
36
|
+
"""``git diff`` argv for unstaged/staged changes, optionally against ``ref``."""
|
|
37
|
+
args = ["diff"]
|
|
38
|
+
if staged:
|
|
39
|
+
args.append("--staged")
|
|
40
|
+
if ref:
|
|
41
|
+
args.append(str(ref))
|
|
42
|
+
if path:
|
|
43
|
+
args.extend(["--", str(path)])
|
|
44
|
+
return args
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def log_args(
|
|
48
|
+
limit: int = 20, *, path: str | None = None, oneline: bool = True
|
|
49
|
+
) -> list[str]:
|
|
50
|
+
"""``git log`` argv with a clamped ``limit`` (1-100)."""
|
|
51
|
+
try:
|
|
52
|
+
count = int(limit)
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
count = 20
|
|
55
|
+
count = max(LOG_LIMIT_MIN, min(count, LOG_LIMIT_MAX))
|
|
56
|
+
args = ["log", f"-{count}", "--no-color"]
|
|
57
|
+
if oneline:
|
|
58
|
+
args.append("--oneline")
|
|
59
|
+
if path:
|
|
60
|
+
args.extend(["--", str(path)])
|
|
61
|
+
return args
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def show_args(ref: str = "HEAD") -> list[str]:
|
|
65
|
+
"""``git show`` argv for one commit."""
|
|
66
|
+
return ["show", "--no-color", str(ref or "HEAD")]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def branch_args() -> list[str]:
|
|
70
|
+
"""``git branch`` argv listing local branches."""
|
|
71
|
+
return ["branch", "--list", "--no-color"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def valid_branch(name: str) -> bool:
|
|
75
|
+
"""Whether ``name`` is a safe, plausible branch name.
|
|
76
|
+
|
|
77
|
+
Mirrors the common ``git check-ref-format --branch`` rules that matter
|
|
78
|
+
here without shelling out: no leading dash, no ``..``/``@{``/``//``, and
|
|
79
|
+
no trailing ``/``, ``.``, or ``.lock``.
|
|
80
|
+
"""
|
|
81
|
+
cleaned = str(name).strip()
|
|
82
|
+
if not cleaned or cleaned.startswith("-"):
|
|
83
|
+
return False
|
|
84
|
+
if not _BRANCH_RE.match(cleaned):
|
|
85
|
+
return False
|
|
86
|
+
if ".." in cleaned or "@{" in cleaned or "//" in cleaned:
|
|
87
|
+
return False
|
|
88
|
+
return not cleaned.endswith(("/", ".", ".lock"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _clean_paths(raw: Any) -> list[str]:
|
|
92
|
+
if raw is None:
|
|
93
|
+
return []
|
|
94
|
+
if not isinstance(raw, list):
|
|
95
|
+
raise ValueError("'paths' must be a list of paths.")
|
|
96
|
+
paths: list[str] = []
|
|
97
|
+
for item in raw:
|
|
98
|
+
text = str(item).strip()
|
|
99
|
+
if not text:
|
|
100
|
+
raise ValueError("'paths' entries must be non-empty.")
|
|
101
|
+
paths.append(text)
|
|
102
|
+
return paths
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_write_args(args: dict[str, Any]) -> list[str]:
|
|
106
|
+
"""Build the argv for a ``git_write`` call, raising ``ValueError`` when invalid."""
|
|
107
|
+
action = str(args.get("action") or "").strip().lower()
|
|
108
|
+
if action in {"stage", "unstage"}:
|
|
109
|
+
paths = _clean_paths(args.get("paths"))
|
|
110
|
+
if not paths:
|
|
111
|
+
raise ValueError(f"'paths' is required for {action}.")
|
|
112
|
+
if action == "stage":
|
|
113
|
+
return ["add", "--", *paths]
|
|
114
|
+
return ["restore", "--staged", "--", *paths]
|
|
115
|
+
if action == "commit":
|
|
116
|
+
message = str(args.get("message") or "").strip()
|
|
117
|
+
if not message:
|
|
118
|
+
raise ValueError("A non-empty 'message' is required for commit.")
|
|
119
|
+
return ["commit", "-m", message]
|
|
120
|
+
if action == "checkout_branch":
|
|
121
|
+
branch = str(args.get("branch") or "").strip()
|
|
122
|
+
if not valid_branch(branch):
|
|
123
|
+
raise ValueError(f"Invalid branch name '{branch}'.")
|
|
124
|
+
return ["checkout", "-b", branch]
|
|
125
|
+
raise ValueError(f"Unknown git_write action '{action}'.")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run_git(
|
|
129
|
+
args: list[str], session: Any, timeout: int = DEFAULT_TIMEOUT
|
|
130
|
+
) -> tuple[int, str]:
|
|
131
|
+
"""Run ``git`` with ``args`` in the session workspace.
|
|
132
|
+
|
|
133
|
+
Returns ``(returncode, output)`` where output is stdout plus stderr. Never
|
|
134
|
+
raises: a missing binary and a timeout become synthetic exit codes so the
|
|
135
|
+
tool can report them as text.
|
|
136
|
+
"""
|
|
137
|
+
command = ["git", *(str(arg) for arg in args)]
|
|
138
|
+
try:
|
|
139
|
+
proc = subprocess.run(
|
|
140
|
+
command,
|
|
141
|
+
cwd=str(session.workspace_root),
|
|
142
|
+
capture_output=True,
|
|
143
|
+
text=True,
|
|
144
|
+
timeout=timeout,
|
|
145
|
+
check=False,
|
|
146
|
+
)
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
return 127, "git is not installed or not on PATH."
|
|
149
|
+
except subprocess.TimeoutExpired:
|
|
150
|
+
return 124, f"git timed out after {timeout}s."
|
|
151
|
+
output = proc.stdout or ""
|
|
152
|
+
if proc.stderr:
|
|
153
|
+
output = f"{output}\n{proc.stderr}" if output else proc.stderr
|
|
154
|
+
return proc.returncode, output
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def preview(args: dict[str, Any], session: Any) -> str:
|
|
158
|
+
"""Return the exact ``git ...`` command line for the approval prompt."""
|
|
159
|
+
del session
|
|
160
|
+
try:
|
|
161
|
+
command = build_write_args(args)
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
return str(exc)
|
|
164
|
+
return "git " + " ".join(shlex.quote(part) for part in command)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Headless one-shot agent runs shared by the CLI and the SDK.
|
|
2
|
+
|
|
3
|
+
The CLI's ``-p/--print`` mode and :mod:`kiwimatecoder.sdk` both drive the
|
|
4
|
+
regular :class:`~kiwimatecoder.agent.Agent` through this module so session
|
|
5
|
+
construction, event collection, and cost accounting live in one place.
|
|
6
|
+
|
|
7
|
+
Rendering stays with the caller: pass ``on_event`` to receive
|
|
8
|
+
``(name, payload)`` notifications for every emitted event and ``console`` for
|
|
9
|
+
the agent's own tool/progress output. Without a console the run is silent.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from kiwimatecoder import config, ui
|
|
22
|
+
from kiwimatecoder.agent import Agent
|
|
23
|
+
from kiwimatecoder.permissions import ConfirmFn, PermissionMode
|
|
24
|
+
from kiwimatecoder.pricing import estimate_cost
|
|
25
|
+
from kiwimatecoder.session import Session
|
|
26
|
+
|
|
27
|
+
EventCallback = Callable[[str, dict[str, Any]], None]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RunOutcome:
|
|
32
|
+
"""Everything one headless run produces for its caller."""
|
|
33
|
+
|
|
34
|
+
text: str = ""
|
|
35
|
+
usage: dict[str, int] = field(default_factory=dict)
|
|
36
|
+
cost_usd: float | None = None
|
|
37
|
+
provider: str = ""
|
|
38
|
+
model: str = ""
|
|
39
|
+
mode: str = ""
|
|
40
|
+
tools_used: list[str] = field(default_factory=list)
|
|
41
|
+
messages: int = 0
|
|
42
|
+
success: bool = True
|
|
43
|
+
error: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_workspace(workspace: str | Path | None) -> Path:
|
|
47
|
+
root = Path(workspace).expanduser() if workspace is not None else Path.cwd()
|
|
48
|
+
if not root.is_dir():
|
|
49
|
+
raise ValueError(f"Workspace is not a directory: {root}")
|
|
50
|
+
return root.resolve()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_session(
|
|
54
|
+
*,
|
|
55
|
+
workspace: str | Path | None = None,
|
|
56
|
+
provider: str | None = None,
|
|
57
|
+
model: str | None = None,
|
|
58
|
+
mode: PermissionMode | str | None = None,
|
|
59
|
+
) -> Session:
|
|
60
|
+
"""Build a fresh session exactly like the interactive launch path.
|
|
61
|
+
|
|
62
|
+
``provider``/``model``/``mode`` override the configured values for this
|
|
63
|
+
run only; nothing is persisted. An overriding ``provider`` becomes the
|
|
64
|
+
primary, ahead of the configured roster. Raises ``ValueError`` for an
|
|
65
|
+
unknown workspace or mode, ``config.ModelNotChosenError`` (a
|
|
66
|
+
``ValueError``) when no model was passed or chosen for the provider, and
|
|
67
|
+
``KeyError`` for an unknown provider.
|
|
68
|
+
"""
|
|
69
|
+
cfg = config.load_config()
|
|
70
|
+
provider_id = provider or config.get_selected_provider_id(cfg)
|
|
71
|
+
provider_cfg = config.get_provider_config(provider_id, cfg)
|
|
72
|
+
resolved_model = config.require_model(provider_cfg, cfg, override=model)
|
|
73
|
+
roster = config.get_active_provider_ids(cfg)
|
|
74
|
+
active_provider_ids = [provider_id, *(pid for pid in roster if pid != provider_id)]
|
|
75
|
+
if mode is None:
|
|
76
|
+
try:
|
|
77
|
+
resolved_mode = PermissionMode.from_str(str(cfg.get("default_mode", "ask")))
|
|
78
|
+
except ValueError:
|
|
79
|
+
resolved_mode = PermissionMode.ASK
|
|
80
|
+
else:
|
|
81
|
+
resolved_mode = PermissionMode.from_str(mode) if isinstance(mode, str) else mode
|
|
82
|
+
return Session(
|
|
83
|
+
provider_id=provider_id,
|
|
84
|
+
model=resolved_model,
|
|
85
|
+
mode=resolved_mode,
|
|
86
|
+
workspace_root=_resolve_workspace(workspace),
|
|
87
|
+
active_provider_ids=active_provider_ids,
|
|
88
|
+
always_allowed=set(config.get_always_allowed_tools(cfg)),
|
|
89
|
+
output_style=config.get_output_style(cfg),
|
|
90
|
+
custom_system_prompt=config.get_system_prompt(cfg),
|
|
91
|
+
command_rules=config.get_command_rules(cfg),
|
|
92
|
+
trusted_workspace=config.get_trusted_workspace(cfg),
|
|
93
|
+
verify_command=config.get_verify_command(cfg),
|
|
94
|
+
compact_at_tokens=config.get_compact_at_tokens(cfg),
|
|
95
|
+
context_window=config.get_context_window(cfg),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _apply_overrides(
|
|
100
|
+
session: Session,
|
|
101
|
+
*,
|
|
102
|
+
workspace: str | Path | None,
|
|
103
|
+
provider: str | None,
|
|
104
|
+
model: str | None,
|
|
105
|
+
mode: PermissionMode | str | None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Apply per-run overrides to an existing session.
|
|
108
|
+
|
|
109
|
+
Raises ``config.ModelNotChosenError`` when switching to a provider with no
|
|
110
|
+
model passed or chosen.
|
|
111
|
+
"""
|
|
112
|
+
if mode is not None:
|
|
113
|
+
session.mode = PermissionMode.from_str(mode) if isinstance(mode, str) else mode
|
|
114
|
+
if provider and provider != session.provider_id:
|
|
115
|
+
provider_cfg = config.get_provider_config(provider)
|
|
116
|
+
session.set_provider(provider, config.require_model(provider_cfg, override=model))
|
|
117
|
+
session.active_provider_ids = [
|
|
118
|
+
provider,
|
|
119
|
+
*(pid for pid in session.active_provider_ids if pid != provider),
|
|
120
|
+
]
|
|
121
|
+
elif model:
|
|
122
|
+
session.model = model
|
|
123
|
+
if workspace is not None:
|
|
124
|
+
session.workspace_root = _resolve_workspace(workspace)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _deny(summary: str, preview: str | None) -> bool:
|
|
128
|
+
"""Default approval behavior for runs with no interactive user."""
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _final_text(session: Session, deltas: list[str]) -> str:
|
|
133
|
+
"""The last assistant message, falling back to the streamed text."""
|
|
134
|
+
for message in reversed(session.messages):
|
|
135
|
+
if message.get("role") == "assistant" and message.get("content"):
|
|
136
|
+
return str(message["content"])
|
|
137
|
+
return "".join(deltas)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def shutdown_runtime() -> None:
|
|
141
|
+
"""Stop anything a headless run lazily started (MCP, LSP, browser)."""
|
|
142
|
+
from kiwimatecoder import browser, lsp, mcp
|
|
143
|
+
|
|
144
|
+
manager = mcp.get_manager()
|
|
145
|
+
if manager is not None:
|
|
146
|
+
mcp.set_manager(None)
|
|
147
|
+
manager.shutdown()
|
|
148
|
+
lsp_manager = lsp.get_manager()
|
|
149
|
+
if lsp_manager is not None:
|
|
150
|
+
lsp.set_manager(None)
|
|
151
|
+
lsp_manager.shutdown()
|
|
152
|
+
browser.reset_driver()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def run_agent_once(
|
|
156
|
+
prompt: str,
|
|
157
|
+
*,
|
|
158
|
+
workspace: str | Path | None = None,
|
|
159
|
+
provider: str | None = None,
|
|
160
|
+
model: str | None = None,
|
|
161
|
+
mode: PermissionMode | str | None = None,
|
|
162
|
+
confirm: ConfirmFn | None = None,
|
|
163
|
+
on_event: EventCallback | None = None,
|
|
164
|
+
max_turns: int | None = 30,
|
|
165
|
+
console: Console | None = None,
|
|
166
|
+
session: Session | None = None,
|
|
167
|
+
render_text: bool = True,
|
|
168
|
+
output_mode: str | None = None,
|
|
169
|
+
) -> RunOutcome:
|
|
170
|
+
"""Drive one agent turn and return its outcome.
|
|
171
|
+
|
|
172
|
+
Approvals are denied when ``confirm`` is omitted. Text deltas are written
|
|
173
|
+
to ``console`` unless ``render_text`` is False; every emitted event is
|
|
174
|
+
forwarded to ``on_event``. Lazily started managers are shut down before
|
|
175
|
+
returning.
|
|
176
|
+
"""
|
|
177
|
+
if session is None:
|
|
178
|
+
session = build_session(
|
|
179
|
+
workspace=workspace, provider=provider, model=model, mode=mode
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
_apply_overrides(
|
|
183
|
+
session, workspace=workspace, provider=provider, model=model, mode=mode
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if console is None:
|
|
187
|
+
console = Console(file=io.StringIO(), no_color=True, highlight=False)
|
|
188
|
+
|
|
189
|
+
text_parts: list[str] = []
|
|
190
|
+
tools_used: list[str] = []
|
|
191
|
+
errors: list[str] = []
|
|
192
|
+
done_reasons: list[str] = []
|
|
193
|
+
|
|
194
|
+
def collect(name: str, payload: dict[str, Any]) -> None:
|
|
195
|
+
if name == "text_delta":
|
|
196
|
+
text_parts.append(str(payload.get("text") or ""))
|
|
197
|
+
elif name == "tool_start":
|
|
198
|
+
tool = str(payload.get("tool") or "")
|
|
199
|
+
if tool and tool not in tools_used:
|
|
200
|
+
tools_used.append(tool)
|
|
201
|
+
elif name == "error":
|
|
202
|
+
errors.append(str(payload.get("error") or "provider error"))
|
|
203
|
+
elif name == "done":
|
|
204
|
+
reason = str(payload.get("reason") or "")
|
|
205
|
+
done_reasons.append(reason)
|
|
206
|
+
if reason == "budget" and payload.get("error"):
|
|
207
|
+
errors.append(str(payload["error"]))
|
|
208
|
+
if on_event is not None:
|
|
209
|
+
on_event(name, payload)
|
|
210
|
+
|
|
211
|
+
ui_config = ui.ui_config()
|
|
212
|
+
agent = Agent(
|
|
213
|
+
session,
|
|
214
|
+
console,
|
|
215
|
+
confirm if confirm is not None else _deny,
|
|
216
|
+
ascii_mode=ui_config["ascii"],
|
|
217
|
+
output_mode=output_mode or ui_config["output_mode"],
|
|
218
|
+
event_handler=collect,
|
|
219
|
+
max_turns=max_turns,
|
|
220
|
+
render_text=render_text,
|
|
221
|
+
spinner=ui_config["spinner"],
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
await agent.run_turn(prompt)
|
|
226
|
+
finally:
|
|
227
|
+
shutdown_runtime()
|
|
228
|
+
|
|
229
|
+
if max_turns is not None and "max_turns" in done_reasons:
|
|
230
|
+
errors.append(
|
|
231
|
+
f"Reached the maximum of {max_turns} tool-loop turn(s) without a "
|
|
232
|
+
"final answer."
|
|
233
|
+
)
|
|
234
|
+
error = errors[0] if errors else None
|
|
235
|
+
return RunOutcome(
|
|
236
|
+
text=_final_text(session, text_parts),
|
|
237
|
+
usage={
|
|
238
|
+
"prompt_tokens": session.prompt_tokens,
|
|
239
|
+
"completion_tokens": session.completion_tokens,
|
|
240
|
+
},
|
|
241
|
+
cost_usd=estimate_cost(
|
|
242
|
+
session.prompt_tokens,
|
|
243
|
+
session.completion_tokens,
|
|
244
|
+
session.model,
|
|
245
|
+
session.provider_id,
|
|
246
|
+
is_local=session.provider.is_local,
|
|
247
|
+
),
|
|
248
|
+
provider=session.provider_id,
|
|
249
|
+
model=session.model,
|
|
250
|
+
mode=session.mode.value,
|
|
251
|
+
tools_used=tools_used,
|
|
252
|
+
messages=len(session.messages),
|
|
253
|
+
success=error is None,
|
|
254
|
+
error=error,
|
|
255
|
+
)
|