ai-code-engineer 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 (44) hide show
  1. ai_code_engineer/__init__.py +2 -0
  2. ai_code_engineer/catalog.py +143 -0
  3. ai_code_engineer/chat.py +181 -0
  4. ai_code_engineer/cli.py +384 -0
  5. ai_code_engineer/config.py +405 -0
  6. ai_code_engineer/engine.py +1282 -0
  7. ai_code_engineer/errors.py +27 -0
  8. ai_code_engineer/git_integration.py +443 -0
  9. ai_code_engineer/gui.py +2646 -0
  10. ai_code_engineer/host.py +81 -0
  11. ai_code_engineer/ignore.py +269 -0
  12. ai_code_engineer/intent.py +222 -0
  13. ai_code_engineer/labels.py +871 -0
  14. ai_code_engineer/memory.py +91 -0
  15. ai_code_engineer/modes.py +156 -0
  16. ai_code_engineer/overrides.py +540 -0
  17. ai_code_engineer/planbook.py +192 -0
  18. ai_code_engineer/providers.py +404 -0
  19. ai_code_engineer/redaction.py +54 -0
  20. ai_code_engineer/repair.py +564 -0
  21. ai_code_engineer/report.py +352 -0
  22. ai_code_engineer/runner.py +854 -0
  23. ai_code_engineer/setup.py +386 -0
  24. ai_code_engineer/symbols.py +1286 -0
  25. ai_code_engineer/verification.py +218 -0
  26. ai_code_engineer/webapp/__init__.py +1 -0
  27. ai_code_engineer/webapp/__main__.py +45 -0
  28. ai_code_engineer/webapp/contract.py +36 -0
  29. ai_code_engineer/webapp/controller.py +3556 -0
  30. ai_code_engineer/webapp/fake.py +1141 -0
  31. ai_code_engineer/webapp/launch.py +108 -0
  32. ai_code_engineer/webapp/server.py +349 -0
  33. ai_code_engineer/webapp/static/app.css +780 -0
  34. ai_code_engineer/webapp/static/app.js +2118 -0
  35. ai_code_engineer/webapp/static/boot.js +19 -0
  36. ai_code_engineer/webapp/static/index.html +89 -0
  37. ai_code_engineer/webapp/static/tokens.css +173 -0
  38. ai_code_engineer/workspace.py +385 -0
  39. ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
  40. ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
  41. ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
  42. ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
  43. ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,405 @@
1
+ """Settings, the provider table, and the one endpoint policy every caller shares.
2
+
3
+ The provider list lives here rather than in ``providers`` because both ``validate`` and the
4
+ windows need it, and ``providers`` imports this module — the other direction would be a cycle.
5
+
6
+ This module is also the only place in the codebase that names a model server's address. A URL resolves
7
+ in one order everywhere: what the person typed, a signed override row (``overrides.py``), what their
8
+ environment says for that provider (``OLLAMA_HOST``, ``GROQ_BASE_URL`` and the rest of
9
+ ``Kind.url_env``), then the provider's own row.
10
+ """
11
+ import os
12
+ from dataclasses import dataclass, replace
13
+ from pathlib import Path
14
+ from urllib.parse import urlparse
15
+ import re
16
+ import tomllib
17
+
18
+ from .errors import AgentError, PolicyError
19
+ from . import overrides
20
+
21
+ # The folder this tool keeps its own records in — the same one `cli.app_dir()` and `webapp/launch.py`
22
+ # compute. It is spelled here as well because a profile read from the terminal has no window to ask.
23
+ APP_DIR = Path(__file__).resolve().parents[2]
24
+ from . import overrides
25
+
26
+ # The request timeout is the one number a user can set in either window, and the saved registry,
27
+ # the browser's number input and Tk's spinbox all disagree about it by construction. The range
28
+ # lives here once; the widgets advertise these and every setter clamps through this function.
29
+ REQUEST_TIMEOUT_DEFAULT = 300
30
+ REQUEST_TIMEOUT_LOW = 30
31
+ REQUEST_TIMEOUT_HIGH = 900
32
+
33
+
34
+ def clamp_request_timeout(value, fallback: int = REQUEST_TIMEOUT_DEFAULT) -> int:
35
+ """One request timeout inside the advertised range, unreadable input included.
36
+
37
+ A caller that must keep the previous value on garbage passes it as ``fallback``.
38
+ """
39
+ try:
40
+ number = int(value)
41
+ except (TypeError, ValueError, RuntimeError): # RuntimeError catches tkinter's TclError
42
+ return fallback
43
+ return min(REQUEST_TIMEOUT_HIGH, max(REQUEST_TIMEOUT_LOW, number))
44
+
45
+
46
+ LOOPBACK = frozenset(("127.0.0.1", "localhost", "::1"))
47
+ ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Kind:
52
+ """One provider the tool can talk to, and the rules that come with it.
53
+
54
+ ``cloud`` is not a description of the vendor: it is the consent switch. A cloud row refuses to
55
+ run until the user has approved sending code off the device, because nothing here can prove
56
+ where a remote server actually is.
57
+ """
58
+ key: str # the Settings.provider value
59
+ label: str # what both windows show
60
+ base: str # default endpoint; "" means the user must type one
61
+ shape: str # "ollama" | "openai" — which wire protocol to speak
62
+ cloud: bool # consent required before any request
63
+ needs_key: bool # an API key is required
64
+ key_env: str # environment variable that may supply it
65
+ routing: bool = False # OpenRouter only: upstream routing block and model echo
66
+ free_only: bool = False # OpenRouter only: a paid model needs an explicit choice
67
+ verified: tuple[str, ...] = () # names to offer when a live list cannot be fetched
68
+ # The variable that may supply this provider's base URL, named the way that server already
69
+ # documents it rather than the way this tool would invent. A row with "" has no such convention.
70
+ url_env: str = ""
71
+
72
+
73
+ KINDS = (
74
+ Kind("ollama", "Ollama", "http://127.0.0.1:11434", "ollama", False, False, "",
75
+ url_env="OLLAMA_HOST"),
76
+ Kind("lmstudio", "LM Studio", "http://localhost:1234/v1", "openai", False, False, "",
77
+ url_env="LMSTUDIO_HOST"),
78
+ Kind("vllm", "vLLM", "http://localhost:8000/v1", "openai", False, False, "", url_env="VLLM_HOST"),
79
+ Kind("openai", "OpenAI", "https://api.openai.com/v1", "openai", True, True, "OPENAI_API_KEY",
80
+ verified=("gpt-4o-mini", "gpt-4o"), url_env="OPENAI_BASE_URL"),
81
+ Kind("groq", "Groq", "https://api.groq.com/openai/v1", "openai", True, True, "GROQ_API_KEY",
82
+ verified=("llama-3.3-70b-versatile", "llama-3.1-8b-instant"), url_env="GROQ_BASE_URL"),
83
+ Kind("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "openai", True, True,
84
+ "DEEPSEEK_API_KEY", verified=("deepseek-chat", "deepseek-reasoner"),
85
+ url_env="DEEPSEEK_BASE_URL"),
86
+ Kind("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai", True, True,
87
+ "OPENROUTER_API_KEY", routing=True, free_only=True, url_env="OPENROUTER_BASE_URL"),
88
+ # A user-typed base URL. The shape is OpenAI-compatible; the trust is decided by the host, so
89
+ # loopback is local and anything else is treated exactly like a cloud row.
90
+ Kind("generic", "Custom endpoint", "", "openai", False, False, "", url_env="AGENT_ENDPOINT"),
91
+ )
92
+ BY_KEY = {kind.key: kind for kind in KINDS}
93
+ OLLAMA = BY_KEY["ollama"]
94
+ OPENROUTER = BY_KEY["openrouter"]
95
+ GENERIC = BY_KEY["generic"]
96
+ DEFAULT_KIND = OLLAMA
97
+
98
+
99
+ def kind_for(value) -> Kind:
100
+ """The row for a key or a label, or None. Windows carry labels, settings carry keys."""
101
+ if not isinstance(value, str):
102
+ return None
103
+ text = value.strip()
104
+ return BY_KEY.get(text.casefold()) or next(
105
+ (kind for kind in KINDS if kind.label.casefold() == text.casefold()), None)
106
+
107
+
108
+ def needs_consent(kind: Kind, endpoint: str) -> bool:
109
+ """Whether this pair needs the cloud approval. A custom URL is judged by its host, not its name."""
110
+ return kind.cloud or (kind.key == GENERIC.key and not is_loopback(endpoint))
111
+
112
+
113
+ def free_mode(kind: Kind) -> str:
114
+ return f"{kind.label} \u00b7 Free"
115
+
116
+
117
+ def paid_mode(kind: Kind) -> str:
118
+ return f"{kind.label} \u00b7 Paid"
119
+
120
+
121
+ def mode_rows() -> tuple[tuple[str, Kind], ...]:
122
+ """The provider list as both windows show it: ``(label, kind)`` pairs.
123
+
124
+ OpenRouter is two rows because its free list and its paid list differ in what they cost, which
125
+ is a decision per task rather than a setting. Every other row is one provider. This lives here
126
+ so the Tk window and the web controller cannot drift apart again the way ``MODES`` and the
127
+ catalog URLs did.
128
+ """
129
+ rows = []
130
+ for kind in KINDS:
131
+ if kind.free_only:
132
+ rows.append((free_mode(kind), kind))
133
+ rows.append((paid_mode(kind), kind))
134
+ else:
135
+ rows.append((kind.label, kind))
136
+ return tuple(rows)
137
+
138
+
139
+ MODES = tuple(label for label, _ in mode_rows())
140
+ MODE_KIND = dict(mode_rows())
141
+
142
+
143
+ def mode_for(provider: str, model: str = "") -> str:
144
+ """The row a profile's provider (and the price class of its model) lands the window on."""
145
+ kind = kind_for(provider)
146
+ if kind is None:
147
+ return ""
148
+ if kind.free_only:
149
+ name = str(model or "")
150
+ if not name or name == "openrouter/free" or name.endswith(":free"):
151
+ return free_mode(kind)
152
+ return paid_mode(kind)
153
+ return kind.label
154
+
155
+
156
+ def is_loopback(endpoint: str) -> bool:
157
+ try:
158
+ return urlparse(endpoint or "").hostname in LOOPBACK
159
+ except ValueError:
160
+ return False
161
+
162
+
163
+ def env_url(kind: Kind) -> str:
164
+ """The base URL this provider's own environment variable supplies, or "".
165
+
166
+ Normalising is the whole job here, because `OLLAMA_HOST` is documented as `host:port` with no
167
+ scheme: a value copied out of a shell profile has to become a URL the same policy can judge. A
168
+ variable set to nothing is not a value — the table's own base answers then.
169
+ """
170
+ if not kind.url_env:
171
+ return ""
172
+ raw = str(os.environ.get(kind.url_env) or "").strip()
173
+ if not raw:
174
+ return ""
175
+ return raw if "://" in raw else "http://" + raw
176
+
177
+
178
+ def endpoint_override(app_dir, kind: Kind) -> str:
179
+ """The address this provider's signed row supplies, or "" when the store has nothing to say.
180
+
181
+ ``app_dir`` is a caller's, never a guess here: a resolution that read whichever folder the process
182
+ happened to start in is how two surfaces end up disagreeing about where their traffic goes.
183
+ """
184
+ if app_dir is None:
185
+ return ""
186
+ value = overrides.values(app_dir, kind.key).get("endpoint")
187
+ return str(value or "").strip()
188
+
189
+
190
+ def default_endpoint(kind: Kind, app_dir=None) -> str:
191
+ """What the endpoint would be if nobody typed one, in the same order the gates resolve it.
192
+
193
+ A window that showed ``kind.base`` here could be advertising a URL its own policy then refuses,
194
+ which is the difference between a default and a hint that lies.
195
+ """
196
+ return endpoint_override(app_dir, kind) or env_url(kind) or kind.base
197
+
198
+
199
+ def check_endpoint(kind: Kind, endpoint) -> str:
200
+ """The base URL for a provider, validated and normalised, or a PolicyError saying why not.
201
+
202
+ The order is one rule with three sources: what the person typed in this window, what their
203
+ environment says for that provider, and what the provider's own row in ``KINDS`` carries. Nothing
204
+ else in the codebase names a model URL, which is what keeps a fourth provider from being a code
205
+ change rather than a table entry and a profile.
206
+
207
+ Paths are allowed — they are the whole shape of an OpenAI-compatible base
208
+ (``http://localhost:1234/v1``) — which is what the old loopback rule got wrong. What stays
209
+ refused: any scheme but http/https, credentials in the URL, a query or a fragment, and a
210
+ provider that is not supposed to leave the machine doing exactly that.
211
+ """
212
+ typed = str(endpoint or "").strip()
213
+ from_env = not typed and bool(env_url(kind))
214
+ where = f" (set by {kind.url_env})" if from_env else ""
215
+ raw = typed or env_url(kind) or kind.base
216
+ if not raw:
217
+ raise PolicyError("This provider needs an endpoint before it can be used.")
218
+ try:
219
+ url = urlparse(raw)
220
+ except ValueError:
221
+ raise PolicyError("The endpoint is not a valid URL.") from None
222
+ if url.scheme not in {"http", "https"} or not url.hostname:
223
+ raise PolicyError("The endpoint must be an http or https URL." + where)
224
+ if url.username or url.password:
225
+ raise PolicyError("The endpoint must not carry credentials." + where)
226
+ if url.query or url.fragment:
227
+ raise PolicyError("The endpoint must not carry a query or a fragment." + where)
228
+ local = url.hostname in LOOPBACK
229
+ if not kind.cloud and kind.key != GENERIC.key and not local:
230
+ raise PolicyError(f"{kind.label} is a local provider: the endpoint must be this device "
231
+ "(127.0.0.1, localhost or ::1)." + where)
232
+ # Cleartext to another machine is the one shape that leaks a key, so it is refused for everyone.
233
+ # A cloud row aimed at loopback is allowed: that is a local proxy on the user's own device, and
234
+ # ``make_provider`` still gates it behind the cloud consent switch before it can be reached.
235
+ if needs_consent(kind, raw) and not local and url.scheme != "https":
236
+ raise PolicyError(f"{kind.label} at a remote address sends your code and your key over the "
237
+ "internet, so the endpoint must be https." + where)
238
+ return raw.rstrip("/")
239
+
240
+
241
+ # The smallest budget a task can start inside. The instruction block, the task line and a repository
242
+ # map are spent before any of the project's own files are read, so a number under this one cannot be
243
+ # fixed by choosing a smaller repository -- every task fails on the first turn. It is refused where it
244
+ # is set, with a range in the message, rather than failing later with a sentence that blames the
245
+ # project. `test_the_smallest_budget_still_starts` keeps the two numbers from drifting apart.
246
+ MIN_CONTEXT_CHARS = 6000
247
+
248
+ # The number ranges, written once. `overrides.py` prints these into the file it creates and judges a
249
+ # row against them through ``validate``, so a limit cannot be restated in the module that stores them —
250
+ # a second table is what eventually disagrees with the first.
251
+ LIMITS = {"max_turns": (1, 30), "timeout_seconds": (1, 900),
252
+ "context_chars": (MIN_CONTEXT_CHARS, 100000), "output_tokens": (256, 8192)}
253
+
254
+
255
+ @dataclass(frozen=True)
256
+ class Settings:
257
+ provider: str = "ollama"
258
+ model: str = "qwen2.5-coder:1.5b"
259
+ # No address of its own. "" means "ask the order `check_endpoint` implements": the profile, then
260
+ # the provider's environment variable, then its row in the table. A default written here would be
261
+ # one more place a URL is hardcoded, and the one that got read for a provider it never named.
262
+ endpoint: str = ""
263
+ max_turns: int = 12
264
+ timeout_seconds: int = 120
265
+ context_chars: int = 24000
266
+ output_tokens: int = 4096
267
+ # The *name* of the variable that holds a key, never a key. A profile that carried a value would
268
+ # put a credential in a file that is meant to be committed.
269
+ api_key_env: str = ""
270
+
271
+
272
+ def apply_overrides(settings: Settings, app_dir=None, *, keep=()) -> Settings:
273
+ """Put this provider's signed rows under the values the caller is actually looking at.
274
+
275
+ A row never carries an address or a provider, and that is the whole safety of the layer: where the
276
+ code and the key go stays decided by the profile or the field on screen, and swapping which vendor
277
+ answers a task from a side file is the failure ``providers.py`` already refuses upstream fallbacks
278
+ for. Everything else a row states outranks a profile line and a table default — that is the point of
279
+ a file the program writes rather than the operator. ``keep`` names the exceptions a caller states:
280
+ a window passes `model`, because the model its list has selected is the one being reviewed.
281
+
282
+ ``app_dir=None`` keeps the store out of the resolution entirely, which is what a caller with no
283
+ records directory means, and what every test that has never heard of overrides relies on.
284
+ """
285
+ if app_dir is None:
286
+ return settings
287
+ kind = kind_for(settings.provider)
288
+ if kind is None:
289
+ return settings
290
+ changes = {key: value for key, value in overrides.values(app_dir, kind.key).items()
291
+ if key != "endpoint" and key not in keep}
292
+ if not changes:
293
+ return settings
294
+ merged = replace(settings, **changes)
295
+ try:
296
+ validate(merged)
297
+ except AgentError:
298
+ # Individually valid rows can still stop fitting the rules this version has. Refusing the set
299
+ # silently would be the exact thing the signature exists to prevent, and raising would fail a
300
+ # task that has nothing to do with the row, so: named, dropped, and the profile is used.
301
+ for key in changes:
302
+ overrides.note(app_dir, key, "no-longer-valid")
303
+ return settings
304
+ return merged
305
+
306
+
307
+ def load_settings(path: Path | None, app_dir=None) -> Settings:
308
+ if path is None:
309
+ # No profile read, so nothing was stated for it: the rows fill in the defaults, and an address
310
+ # they supply resolves through the same order a profile's own line would.
311
+ settings = Settings()
312
+ where = endpoint_override(app_dir, kind_for(settings.provider) or DEFAULT_KIND)
313
+ return apply_overrides(replace(settings, endpoint=where) if where else settings, app_dir)
314
+ try:
315
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
316
+ if set(data) - {"model", "limits"}:
317
+ raise AgentError("Unknown configuration section.")
318
+ model = data.get("model", {})
319
+ limits = data.get("limits", {})
320
+ if not isinstance(model, dict) or not isinstance(limits, dict):
321
+ raise AgentError("Configuration model and limits must be TOML tables.")
322
+ if set(model) - {"provider", "name", "endpoint", "api_key_env"} or set(limits) - {
323
+ "max_turns", "timeout_seconds", "context_chars", "output_tokens"
324
+ }:
325
+ raise AgentError("Unknown configuration field.")
326
+ settings = Settings(
327
+ provider=model.get("provider", "ollama"),
328
+ model=model.get("name", "qwen2.5-coder:1.5b"),
329
+ api_key_env=model.get("api_key_env", ""),
330
+ **limits,
331
+ )
332
+ # The endpoint is resolved against the provider this same file names, through the one function
333
+ # that owns the order. It used to fall back to a literal Ollama address here, which meant a
334
+ # cloud profile that forgot its endpoint validated — loopback passes the https rule — and then
335
+ # sent the task to a local port instead of to the vendor its own name.
336
+ kind = kind_for(settings.provider)
337
+ if kind is None:
338
+ raise AgentError("Provider must be one of: " + ", ".join(item.key for item in KINDS) + ".")
339
+ # An address the profile spells out is the one thing a row never replaces; when it names none,
340
+ # the order is the row, then the provider's own environment variable, then its table entry.
341
+ typed = str(model.get("endpoint", "") or "").strip()
342
+ settings = replace(settings, endpoint=check_endpoint(
343
+ kind, typed or endpoint_override(app_dir, kind)))
344
+ settings = apply_overrides(settings, app_dir)
345
+ validate(settings)
346
+ return settings
347
+ except (OSError, ValueError, TypeError) as exc:
348
+ raise AgentError("Configuration is unreadable or invalid TOML/types.") from exc
349
+
350
+
351
+ def validate(settings: Settings) -> None:
352
+ kind = kind_for(settings.provider)
353
+ if kind is None:
354
+ raise AgentError("Provider must be one of: " + ", ".join(item.key for item in KINDS) + ".")
355
+ if not isinstance(settings.model, str) or not settings.model.strip():
356
+ raise AgentError("A model name is required.")
357
+ if not isinstance(settings.endpoint, str):
358
+ raise AgentError("Endpoint must be a string.")
359
+ if not isinstance(settings.api_key_env, str) or (
360
+ settings.api_key_env and not ENV_NAME.fullmatch(settings.api_key_env)):
361
+ raise AgentError("api_key_env must name an environment variable, not hold a key.")
362
+ check_endpoint(kind, settings.endpoint)
363
+ for name, (low, high) in LIMITS.items():
364
+ value = getattr(settings, name)
365
+ if type(value) is not int or not low <= value <= high:
366
+ raise AgentError(f"{name} must be between {low} and {high}.")
367
+
368
+
369
+ def settings_for(kind: Kind, endpoint: str = "", *, app_dir=None, keep=("model",),
370
+ **changes) -> Settings:
371
+ """A Settings for one provider row, with its own default base when nothing was typed.
372
+
373
+ Both windows used to hand every choice to ``replace(Settings(), …)`` with a literal provider
374
+ name; this is the one place that pairs a kind with the endpoint that kind actually uses.
375
+
376
+ ``keep`` defaults to `model` because that is the one value a window is showing as a decision: the
377
+ model its list has selected is the one a person reviewed and clicked. The other numbers on the
378
+ screen are remembered preferences rather than choices made for this run, so a row outranks them —
379
+ and the Overrides section of both windows lists the rows currently in force, which is what keeps a
380
+ number the field disagrees with from being a surprise.
381
+ """
382
+ settings = replace(Settings(), provider=kind.key,
383
+ endpoint=check_endpoint(kind, endpoint or endpoint_override(app_dir, kind)),
384
+ **changes)
385
+ return apply_overrides(settings, app_dir, keep=keep)
386
+
387
+
388
+ # A profile label arrives from a browser dropdown, so it is a name from a closed shape rather than
389
+ # a path: "local" reads profiles/local.toml and "../../windows/win.ini" reads nothing at all.
390
+ PROFILE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,39}")
391
+ PROFILES_DIR = Path(__file__).resolve().parents[2] / "profiles"
392
+
393
+
394
+ def profile_names(directory: Path | None = None) -> list[str]:
395
+ try:
396
+ return sorted(path.stem for path in (directory or PROFILES_DIR).glob("*.toml")
397
+ if PROFILE_NAME.fullmatch(path.stem))
398
+ except OSError:
399
+ return []
400
+
401
+
402
+ def load_profile(label: str, directory: Path | None = None, app_dir=None) -> Settings:
403
+ if not PROFILE_NAME.fullmatch(str(label or "")):
404
+ raise AgentError("Unknown configuration profile.")
405
+ return load_settings((directory or PROFILES_DIR) / f"{label}.toml", app_dir)