qikly 0.3.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. qikly/__init__.py +11 -0
  2. qikly/agent_api/agent_interface.py +533 -0
  3. qikly/agent_api/call_llm.py +17 -0
  4. qikly/agent_api/code_loader/code_loader.py +86 -0
  5. qikly/agent_api/code_loader/excerpt.py +194 -0
  6. qikly/agent_api/prompts/acceptance_criteria_prompt.py +5 -0
  7. qikly/agent_api/prompts/acceptance_criteria_review_prompt.py +11 -0
  8. qikly/agent_api/prompts/fix_prompt.py +18 -0
  9. qikly/agent_api/prompts/fixture_proposal_prompt.py +52 -0
  10. qikly/agent_api/prompts/integration_test_prompt.py +5 -0
  11. qikly/agent_api/prompts/patch_prompt.py +32 -0
  12. qikly/agent_api/prompts/system_test_prompt.py +5 -0
  13. qikly/agent_api/prompts/template_loader.py +14 -0
  14. qikly/agent_api/prompts/unit_test_prompt.py +10 -0
  15. qikly/agent_api/providers/anthropic.py +111 -0
  16. qikly/agent_api/providers/gemini.py +60 -0
  17. qikly/agent_api/providers/openai.py +41 -0
  18. qikly/agent_api/providers/router.py +336 -0
  19. qikly/agent_api/providers/timeouts.py +55 -0
  20. qikly/agent_api/retry.py +136 -0
  21. qikly/agent_api/usage.py +266 -0
  22. qikly/agent_tools/apply_patch.py +181 -0
  23. qikly/agent_tools/inspect_code.py +81 -0
  24. qikly/agent_tools/junit.py +96 -0
  25. qikly/agent_tools/run_tests.py +182 -0
  26. qikly/approval.py +107 -0
  27. qikly/cli.py +1397 -0
  28. qikly/console.py +48 -0
  29. qikly/criteria_compare.py +182 -0
  30. qikly/criteria_import.py +161 -0
  31. qikly/explain.py +163 -0
  32. qikly/forecast.py +146 -0
  33. qikly/inputs_public/agent_defs/acceptance_criteria_prompt.md +45 -0
  34. qikly/inputs_public/agent_defs/acceptance_criteria_review_prompt.md +63 -0
  35. qikly/inputs_public/agent_defs/code_agent.md +71 -0
  36. qikly/inputs_public/agent_defs/fix_prompt.md +13 -0
  37. qikly/inputs_public/agent_defs/fix_prompt_ineffective_section.md +20 -0
  38. qikly/inputs_public/agent_defs/fixture_proposal_prompt.md +63 -0
  39. qikly/inputs_public/agent_defs/integration_test_prompt.md +27 -0
  40. qikly/inputs_public/agent_defs/patch_prompt.md +16 -0
  41. qikly/inputs_public/agent_defs/patch_prompt_retry_after_apply_failure.md +20 -0
  42. qikly/inputs_public/agent_defs/patch_prompt_retry_after_generation_failure.md +5 -0
  43. qikly/inputs_public/agent_defs/patch_prompt_retry_after_too_large.md +12 -0
  44. qikly/inputs_public/agent_defs/system_test_prompt.md +27 -0
  45. qikly/inputs_public/agent_defs/test_agent.md +112 -0
  46. qikly/inputs_public/agent_defs/unit_test_prompt.md +26 -0
  47. qikly/inputs_public/config/settings.yaml +77 -0
  48. qikly/inputs_public/config/tasks/AGG_RUNLOG.yaml +51 -0
  49. qikly/inputs_public/config/tasks/CALC_CALENDAR.yaml +53 -0
  50. qikly/inputs_public/config/tasks/CALC_DISCOUNT.yaml +53 -0
  51. qikly/inputs_public/config/tasks/CALC_TAX.yaml +51 -0
  52. qikly/inputs_public/config/tasks/ETL_ADDRESS.yaml +53 -0
  53. qikly/inputs_public/config/tasks/ETL_EMAIL.yaml +51 -0
  54. qikly/inputs_public/config/tasks/ETL_NAME_SPLIT.yaml +50 -0
  55. qikly/inputs_public/config/tasks/MERGE_CONTACTS.yaml +53 -0
  56. qikly/inputs_public/config/tasks/MERGE_SALES.yaml +59 -0
  57. qikly/inputs_public/config/tasks/MERGE_STOCK.yaml +60 -0
  58. qikly/inputs_public/data/AGG_RUNLOG/input_01.jsonl +29 -0
  59. qikly/inputs_public/data/AGG_RUNLOG/input_02.jsonl +22 -0
  60. qikly/inputs_public/data/CALC_CALENDAR/input_01.csv +8 -0
  61. qikly/inputs_public/data/CALC_CALENDAR/input_02.csv +6 -0
  62. qikly/inputs_public/data/CALC_DISCOUNT/input_01.csv +10 -0
  63. qikly/inputs_public/data/CALC_DISCOUNT/input_02.csv +9 -0
  64. qikly/inputs_public/data/CALC_TAX/input_01.csv +8 -0
  65. qikly/inputs_public/data/CALC_TAX/input_02.csv +9 -0
  66. qikly/inputs_public/data/ETL_ADDRESS/input_01.csv +13 -0
  67. qikly/inputs_public/data/ETL_ADDRESS/input_02.csv +13 -0
  68. qikly/inputs_public/data/ETL_EMAIL/input_01.csv +23 -0
  69. qikly/inputs_public/data/ETL_EMAIL/input_02.csv +11 -0
  70. qikly/inputs_public/data/ETL_NAME_SPLIT/input_01.csv +8 -0
  71. qikly/inputs_public/data/ETL_NAME_SPLIT/input_02.csv +7 -0
  72. qikly/inputs_public/data/MERGE_CONTACTS/input_01.csv +11 -0
  73. qikly/inputs_public/data/MERGE_CONTACTS/input_02.csv +7 -0
  74. qikly/inputs_public/data/MERGE_SALES/input_01.csv +12 -0
  75. qikly/inputs_public/data/MERGE_SALES/input_02.csv +9 -0
  76. qikly/inputs_public/data/MERGE_STOCK/input_01.csv +18 -0
  77. qikly/inputs_public/data/MERGE_STOCK/input_02.csv +12 -0
  78. qikly/inputs_public/reference/MERGE_SALES/merge.py +192 -0
  79. qikly/inputs_public/reference/MERGE_STOCK/merge.py +170 -0
  80. qikly/jira.py +192 -0
  81. qikly/orchestrator/live_view.py +228 -0
  82. qikly/orchestrator/orchestrator.py +1105 -0
  83. qikly/orchestrator/reports/aggregate_report.py +591 -0
  84. qikly/orchestrator/reports/metrics_report.py +377 -0
  85. qikly/orchestrator/reports/report.py +502 -0
  86. qikly/orchestrator/reports/trend_report.py +166 -0
  87. qikly/orchestrator/run_all.py +346 -0
  88. qikly/orchestrator/run_summary.py +145 -0
  89. qikly/orchestrator/tuning/check_criteria.py +148 -0
  90. qikly/orchestrator/tuning/diff_acceptance_criteria.py +301 -0
  91. qikly/orchestrator/tuning/gen_and_eval_acceptance_criteria.py +102 -0
  92. qikly/orchestrator/tuning/propose_fixtures.py +208 -0
  93. qikly/orchestrator/tuning/refine_acceptance_criteria.py +281 -0
  94. qikly/paths.py +223 -0
  95. qikly/pr_comment.py +120 -0
  96. qikly/scaffold.py +273 -0
  97. qikly/validate.py +166 -0
  98. qikly/version_check.py +101 -0
  99. qikly-0.3.0.dist-info/METADATA +963 -0
  100. qikly-0.3.0.dist-info/RECORD +104 -0
  101. qikly-0.3.0.dist-info/WHEEL +5 -0
  102. qikly-0.3.0.dist-info/entry_points.txt +6 -0
  103. qikly-0.3.0.dist-info/licenses/LICENSE +201 -0
  104. qikly-0.3.0.dist-info/top_level.txt +1 -0
qikly/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ qikly -- an autonomous coding agent that writes its own acceptance
3
+ criteria, and the tests that enforce them, then converges real code against
4
+ that bar.
5
+
6
+ The subpackages here (orchestrator, agent_api, agent_tools) were top-level
7
+ until they were nested under this one. They are generic enough names that
8
+ installing them at the top level of site-packages would eventually collide
9
+ with somebody else's `orchestrator`.
10
+ """
11
+ __version__ = "0.3.0"
@@ -0,0 +1,533 @@
1
+ from qikly.agent_api.call_llm import call_llm
2
+ from qikly.agent_api.prompts.acceptance_criteria_prompt import build_acceptance_criteria_prompt
3
+ from qikly.agent_api.prompts.acceptance_criteria_review_prompt import build_acceptance_criteria_review_prompt
4
+ from qikly.agent_api.prompts.fixture_proposal_prompt import build_fixture_proposal_prompt, read_fixtures
5
+ from qikly.agent_api.prompts.fix_prompt import build_fix_prompt
6
+ from qikly.agent_api.prompts.patch_prompt import build_patch_prompt
7
+ from qikly.agent_api.prompts.integration_test_prompt import build_integration_test_prompt
8
+ from qikly.agent_api.prompts.system_test_prompt import build_system_test_prompt
9
+ from qikly.agent_api.prompts.unit_test_prompt import build_unit_test_prompt
10
+ from qikly.agent_api.code_loader.code_loader import load_codebase, load_target_files
11
+
12
+ import difflib
13
+ import json
14
+ import os
15
+ import re
16
+
17
+ from qikly.paths import list_input_dir, resolve_input
18
+
19
+ # Resolved per call rather than bound once at import: which file answers
20
+ # "code_agent.md" depends on whether the project has an inputs_private/
21
+ # override, and binding at import time would freeze that decision before a
22
+ # caller has had a chance to chdir into the project.
23
+ def agent_md_path():
24
+ return resolve_input("agent_defs/code_agent.md")
25
+
26
+
27
+ def test_agent_md_path():
28
+ return resolve_input("agent_defs/test_agent.md")
29
+
30
+
31
+ AGENT_SRC_ROOT = "outputs/agent_src/code"
32
+
33
+ _ACCEPTANCE_CRITERIA_RE = re.compile(r"^acceptance_criteria:.*?(?=^\S|\Z)", re.MULTILINE | re.DOTALL)
34
+ _TARGET_FILES_RE = re.compile(r"^target_files:\s*\n((?:[ \t]*-.*\n?)*)", re.MULTILINE)
35
+
36
+ # Known task-type prefixes. Add a new domain family's prefix here when it's
37
+ # added to config/tasks/, so task_type() recognizes it instead of falling
38
+ # back to the full task_id.
39
+ _TASK_TYPES = ("ETL", "CALC", "MERGE")
40
+
41
+
42
+ def task_type(task_id):
43
+ """
44
+ The task-type prefix (the part before the first underscore) -- ETL,
45
+ CALC, MERGE, ... -- used to group findings/reports by domain family
46
+ (see orchestrator/tuning/refine_acceptance_criteria.py's multi-task
47
+ summary). Falls back to the full task_id if it doesn't match a known
48
+ prefix, rather than guessing or raising -- an unrecognized prefix just
49
+ means a new task type that hasn't been added to _TASK_TYPES yet.
50
+ """
51
+ prefix = task_id.split("_", 1)[0]
52
+ return prefix if prefix in _TASK_TYPES else task_id
53
+
54
+
55
+ def task_config_path(task_id):
56
+ """
57
+ config/tasks/<task_id>.yaml -- one file per V&V task, so any number of
58
+ tasks can be defined side by side and selected independently (see
59
+ run.py's task discovery / multiprocessing driver). Resolves to your
60
+ inputs_private/ copy if you have written one, else the bundled example.
61
+ """
62
+ return resolve_input(f"config/tasks/{task_id}.yaml")
63
+
64
+
65
+ def agent_src_code_path(task_id):
66
+ """
67
+ outputs/agent_src/code/<task_id>/ -- each task gets its own code
68
+ subtree. This is what actually makes running multiple tasks
69
+ concurrently (one process per task) safe: without the per-task
70
+ subfolder, two processes would clear and rewrite the same shared
71
+ outputs/agent_src/code/ directory out from under each other.
72
+ """
73
+ return os.path.join(AGENT_SRC_ROOT, task_id)
74
+
75
+
76
+ def _available_task_ids():
77
+ """
78
+ Duplicates orchestrator.orchestrator.discover_task_ids()'s logic rather
79
+ than importing it -- orchestrator.py already imports from this module,
80
+ so importing back would be circular. Both are one call to
81
+ list_input_dir(), low risk of drifting apart.
82
+ """
83
+ return sorted(
84
+ os.path.splitext(os.path.basename(p))[0]
85
+ for p in list_input_dir("config/tasks")
86
+ if p.endswith(".yaml")
87
+ )
88
+
89
+
90
+ def _read_task(task_id):
91
+ path = task_config_path(task_id)
92
+ if not os.path.exists(path):
93
+ suggestion = difflib.get_close_matches(task_id, _available_task_ids(), n=1)
94
+ hint = f" Did you mean {suggestion[0]!r}?" if suggestion else ""
95
+ raise FileNotFoundError(f"task config not found at: {path}.{hint}")
96
+ return open(path).read()
97
+
98
+
99
+ def _task_without_acceptance_criteria(task_id):
100
+ """
101
+ Read this task's config with its acceptance_criteria section removed,
102
+ for the coding agent's FIX/PATCH prompts. Test generation reads the
103
+ task config in full (see agent_generate_*_tests below) --
104
+ acceptance_criteria is the bar QA holds the implementation to, not
105
+ something handed to the developer up front. Coding and test generation
106
+ otherwise share the same model at temperature=0 with a fixed seed, so
107
+ without withholding something, both sides read the spec identically and
108
+ every stage passes on the first attempt. This asymmetry is what makes
109
+ the coding agent's first attempt genuinely fallible, the way a
110
+ developer working from requirements alone would be.
111
+ """
112
+ task_text = _read_task(task_id)
113
+ stripped = _ACCEPTANCE_CRITERIA_RE.sub("", task_text)
114
+ return stripped.rstrip() + "\n"
115
+
116
+
117
+ def _extract_target_files(fix_text):
118
+ """
119
+ Pull the file paths out of a FIX's target_files: list (see
120
+ agent_defs/code_agent.md for the format the model is asked to
121
+ follow). Used so the PATCH prompt only has to load the files the FIX
122
+ said it would touch, not the whole task codebase. Returns [] if the
123
+ section is missing or empty -- callers should fall back to loading
124
+ everything rather than giving the model no code context at all.
125
+ """
126
+ match = _TARGET_FILES_RE.search(fix_text)
127
+ if not match:
128
+ return []
129
+ files = []
130
+ for line in match.group(1).splitlines():
131
+ line = line.strip()
132
+ if line.startswith("-"):
133
+ files.append(line[1:].strip().strip('"').strip("'"))
134
+ return [f for f in files if f]
135
+
136
+
137
+ def agent_generate_acceptance_criteria(task_id, seed=None):
138
+ """
139
+ Proposes acceptance_criteria from a task's requirements/interface/
140
+ description alone, playing the QA-hardening role a human currently has
141
+ to play by hand (see agent_defs/acceptance_criteria_prompt.md).
142
+ Returns a plain list[str] -- deliberately not written to any file, so a
143
+ caller can merge in user-supplied custom criteria with a plain
144
+ `generated + custom` and so this never mutates an existing task's real,
145
+ hand-tuned acceptance_criteria as a side effect.
146
+ """
147
+ task = _task_without_acceptance_criteria(task_id)
148
+ prompt = build_acceptance_criteria_prompt(task)
149
+ raw = call_llm("acceptance_criteria", prompt, seed=seed)
150
+ return _extract_criteria_list(raw)
151
+
152
+
153
+ def _extract_criteria_list(text):
154
+ """
155
+ Parses a `- "..."` YAML-bullet-list response (see
156
+ acceptance_criteria_prompt.md's required output format) into a list of
157
+ strings, tolerant of stray markdown fences the model might add anyway
158
+ (same tolerance _extract_diff/_extract_python already apply elsewhere).
159
+ """
160
+ match = re.search(r"```(?:yaml)?\s*\n(.*?)(?:```|\Z)", text, re.DOTALL)
161
+ body = match.group(1) if match else text
162
+ criteria = []
163
+ for line in body.splitlines():
164
+ line = line.strip()
165
+ if line.startswith("-"):
166
+ item = line[1:].strip()
167
+ if len(item) >= 2 and item.startswith('"') and item.endswith('"'):
168
+ item = item[1:-1]
169
+ if item:
170
+ criteria.append(item)
171
+ return criteria
172
+
173
+
174
+ def _format_criteria_block(criteria):
175
+ """
176
+ Inverse of _extract_criteria_list -- renders a list[str] back into an
177
+ `acceptance_criteria:` YAML section matching the format real task files
178
+ use. Shared by run.py's --generate-criteria criteria seeding
179
+ (orchestrator/orchestrator.py) and
180
+ orchestrator/tuning/refine_acceptance_criteria.py's scratch-file writer,
181
+ so both stay consistent with each other and with how a hand-written
182
+ task file actually looks.
183
+ """
184
+ return "acceptance_criteria:\n" + "\n".join(f" - {json.dumps(c)}" for c in criteria) + "\n"
185
+
186
+
187
+ _CRITERIA_CATEGORIES = {
188
+ "insufficient_strictness", "parsing_looseness", "rounding_precision",
189
+ "silent_failure", "internal_consistency", "domain_normalization",
190
+ "intent_mismatch", "schema_structural", "other",
191
+ }
192
+
193
+
194
+ def agent_generate_acceptance_criteria_review(task_id, current_criteria, implementation_code, seed=None):
195
+ """
196
+ Adversarial follow-up to agent_generate_acceptance_criteria: given the
197
+ criteria proposed so far and a real, converged implementation that
198
+ already satisfies them, asks the model to find what a plausible
199
+ implementation could still get away with (see
200
+ agent_defs/acceptance_criteria_review_prompt.md). This is where
201
+ the loop gets the signal one-shot generation structurally can't have --
202
+ an actual implementation choice to react to, not just the spec text.
203
+ Returns a list[(category, criterion_text)] of NEW findings only,
204
+ possibly empty (an empty list is the loop's stopping signal, not a
205
+ failure). category is one of _CRITERIA_CATEGORIES, grounded in the
206
+ recurring bug shapes actually observed across real runs (see
207
+ acceptance_criteria_review_prompt.md) -- not the same list[str] shape
208
+ agent_generate_acceptance_criteria returns, since a category label is
209
+ round-reporting metadata, not part of the criterion text itself (the
210
+ plain text is what gets fed back into the next round and written into
211
+ a task file, never the tag).
212
+ """
213
+ task = _task_without_acceptance_criteria(task_id)
214
+ prompt = build_acceptance_criteria_review_prompt(task, current_criteria, implementation_code)
215
+ # Its own mode, not "acceptance_criteria": the reviewer is a separate agent
216
+ # with a separate prompt, and it has to be addressable separately to be
217
+ # runnable on a different model from the one that drafted the criteria.
218
+ raw = call_llm("acceptance_criteria_review", prompt, seed=seed)
219
+ return _extract_tagged_criteria_list(raw)
220
+
221
+
222
+ def agent_propose_fixture_rows(task_id, criteria, seed=None):
223
+ """
224
+ Which criteria no existing fixture row can trigger, and what row would fix
225
+ each one. Returns a list of dicts, possibly empty.
226
+
227
+ A criterion nothing can exercise produces a test that passes whatever the
228
+ code does. Roughly two thirds of the faults planted in this project's own
229
+ measurements were caught by nobody for that reason, so the bar was partly
230
+ unmeasurable rather than partly wrong.
231
+
232
+ This NEVER writes to a fixture file. It returns proposals, and the caller
233
+ writes them somewhere a person reads. Two reasons. A row is harder to judge
234
+ than a criterion, because its correctness is relative to the criterion it
235
+ was proposed for rather than visible on its face. And a fixture set that
236
+ grows in whatever direction a model finds interesting stops resembling the
237
+ data you actually process, at which point every rate measured on it
238
+ describes a world that does not exist.
239
+ """
240
+ import yaml
241
+
242
+ with open(task_config_path(task_id), encoding="utf-8") as handle:
243
+ config = yaml.safe_load(handle) or {}
244
+ paths = [p for p in (config.get("inputs") or [])]
245
+ task = _task_without_acceptance_criteria(task_id)
246
+ prompt = build_fixture_proposal_prompt(task, criteria, read_fixtures(paths))
247
+ return _extract_fixture_proposals(call_llm("fixture_proposal", prompt, seed=seed),
248
+ len(criteria))
249
+
250
+
251
+ def _extract_fixture_proposals(text, criteria_count):
252
+ """
253
+ Parse `- [criterion N] [file] row | outcome` lines, and `covered`.
254
+
255
+ Anything that does not match the documented shape is dropped rather than
256
+ guessed at: a malformed row would be written into a proposal file that a
257
+ person is meant to be able to paste from, and half a row is worse than no
258
+ row. A criterion number outside the list is dropped for the same reason,
259
+ since it cannot be reviewed against anything.
260
+ """
261
+ import re
262
+
263
+ out = []
264
+ for line in (text or "").splitlines():
265
+ line = line.strip().lstrip("-").strip()
266
+ head = re.match(r"\[criterion\s+(\d+)\]\s*(.*)", line, re.I)
267
+ if not head:
268
+ continue
269
+ index = int(head.group(1))
270
+ if not 1 <= index <= criteria_count:
271
+ continue
272
+ rest = head.group(2).strip()
273
+ if rest.lower().startswith("covered"):
274
+ out.append({"criterion": index, "covered": True})
275
+ continue
276
+ body = re.match(r"\[([^\]]+)\]\s*(.+?)\s*\|\s*(.+)$", rest)
277
+ if not body:
278
+ continue
279
+ row = body.group(2).strip()
280
+ # A description of a row is not a row. One reply proposed the literal
281
+ # text "(empty file)" to mean "a log with no events", and it was
282
+ # pasted into a .jsonl as a line, breaking the fixture. A proposal has
283
+ # to be the data itself, because the whole point is that it can be
284
+ # appended without further interpretation.
285
+ if row.startswith("(") and row.endswith(")"):
286
+ continue
287
+ out.append({"criterion": index, "covered": False,
288
+ "file": body.group(1).strip(),
289
+ "row": row,
290
+ "outcome": body.group(3).strip()})
291
+ return out
292
+
293
+
294
+ def _extract_tagged_criteria_list(text):
295
+ """
296
+ Parses a `- [category] "..."` list (see
297
+ acceptance_criteria_review_prompt.md's required output format) into a
298
+ list of (category, criterion_text) tuples. Falls back to category
299
+ "other" for a missing or unrecognized tag rather than dropping the
300
+ finding -- a mis-tagged real finding is still a real finding, and a
301
+ parsing quirk here shouldn't silently lose it.
302
+ """
303
+ match = re.search(r"```(?:yaml)?\s*\n(.*?)(?:```|\Z)", text, re.DOTALL)
304
+ body = match.group(1) if match else text
305
+ tag_re = re.compile(r'^-\s*\[(\w+)\]\s*(.*)$')
306
+ results = []
307
+ for line in body.splitlines():
308
+ line = line.strip()
309
+ if not line.startswith("-"):
310
+ continue
311
+ m = tag_re.match(line)
312
+ if m:
313
+ category, item = m.group(1).strip().lower(), m.group(2).strip()
314
+ else:
315
+ category, item = "other", line[1:].strip()
316
+ if category not in _CRITERIA_CATEGORIES:
317
+ category = "other"
318
+ if len(item) >= 2 and item.startswith('"') and item.endswith('"'):
319
+ item = item[1:-1]
320
+ if item:
321
+ results.append((category, item))
322
+ return results
323
+
324
+
325
+ def agent_generate_fix(task_id, failure_info, seed=None, previous_ineffective_patch=None):
326
+ """
327
+ Build FIX prompt and call the LLM to generate a FIX object.
328
+ """
329
+
330
+ path = agent_md_path()
331
+ if not os.path.exists(path):
332
+ raise FileNotFoundError(f"code_agent.md not found at: {path}")
333
+
334
+ agent_md = open(path, encoding="utf-8").read()
335
+ task = _task_without_acceptance_criteria(task_id)
336
+
337
+ prompt = build_fix_prompt(
338
+ agent_md, task, failure_info, previous_ineffective_patch=previous_ineffective_patch
339
+ )
340
+ return call_llm("fix", prompt, seed=seed)
341
+
342
+
343
+ def agent_generate_patch(task_id, fix, seed=None, previous_patch=None, previous_patch_error=None,
344
+ previous_patch_too_large=False):
345
+ """
346
+ Build PATCH prompt and call the LLM to generate a PATCH object. Only
347
+ loads the files the FIX named in its target_files: list, not the whole
348
+ task codebase -- keeps the prompt (and the model's ability to introduce
349
+ an unrelated change) scoped to what the FIX actually said it would
350
+ touch. Falls back to the whole task codebase if target_files couldn't be
351
+ parsed out of the FIX text, rather than giving the model no code at all.
352
+ """
353
+ path = agent_md_path()
354
+ if not os.path.exists(path):
355
+ raise FileNotFoundError(f"code_agent.md not found at: {path}")
356
+
357
+ agent_md = open(path, encoding="utf-8").read()
358
+ task = _task_without_acceptance_criteria(task_id)
359
+ code_dir = agent_src_code_path(task_id)
360
+ target_files = _extract_target_files(fix)
361
+ # The FIX names the files and, in prose, the functions it means to
362
+ # change, so it is also the retrieval query for anything too large to
363
+ # load whole.
364
+ codebase_text = (load_target_files(code_dir, target_files, context=fix)
365
+ if target_files else load_codebase(code_dir))
366
+
367
+ prompt = build_patch_prompt(
368
+ agent_md, task, fix, codebase_text,
369
+ previous_patch=previous_patch, previous_patch_error=previous_patch_error,
370
+ previous_patch_too_large=previous_patch_too_large
371
+ )
372
+ raw_patch = call_llm("patch", prompt, seed=seed)
373
+ diff = _extract_diff(raw_patch)
374
+ return _recount_hunk_headers(diff)
375
+
376
+
377
+ def _load_test_agent_md():
378
+ path = test_agent_md_path()
379
+ if not os.path.exists(path):
380
+ raise FileNotFoundError(f"test_agent.md not found at: {path}")
381
+ return open(path, encoding="utf-8").read()
382
+
383
+
384
+ def read_acceptance_criteria(task_id):
385
+ """This task's acceptance_criteria as a list, or [] if it has none."""
386
+ import yaml
387
+
388
+ data = yaml.safe_load(_read_task(task_id)) or {}
389
+ return list(data.get("acceptance_criteria") or [])
390
+
391
+
392
+ def _task_with_criteria(task_id, criteria):
393
+ """
394
+ The task config text with its acceptance_criteria replaced by `criteria`.
395
+
396
+ Used to generate tests against one batch of the bar at a time. Passing
397
+ None returns the config untouched, which is the single-call behaviour.
398
+ """
399
+ text = _read_task(task_id)
400
+ if criteria is None:
401
+ return text
402
+ block = ("acceptance_criteria:\n"
403
+ + "".join(f" - {json.dumps(c)}\n" for c in criteria))
404
+ # Replace through a callable. re.sub treats a string replacement as a
405
+ # template, so the backslash escapes json.dumps emits get reinterpreted
406
+ # and the result is either a re.error or a tab injected into the YAML.
407
+ return _ACCEPTANCE_CRITERIA_RE.sub(lambda _m: block, text)
408
+
409
+
410
+ def criteria_batches(task_id, per_batch):
411
+ """
412
+ Split this task's criteria into batches of `per_batch`, or [None] when
413
+ batching is off, which means one call carrying the whole bar.
414
+
415
+ Batching exists because the suite was measured not to grow with the bar:
416
+ across 67 archived suites, refinement raised the criteria count 54% and
417
+ the generated suite got 6% smaller, so tests per criterion fell from 2.21
418
+ to 1.32. One call for the whole set appears to produce a roughly fixed
419
+ amount of test code however much it is asked to cover, which dilutes
420
+ coverage rather than adding it.
421
+ """
422
+ if not per_batch or per_batch < 1:
423
+ return [None]
424
+ criteria = read_acceptance_criteria(task_id)
425
+ if len(criteria) <= per_batch:
426
+ return [None]
427
+ return [criteria[i:i + per_batch] for i in range(0, len(criteria), per_batch)]
428
+
429
+
430
+ def agent_generate_integration_tests(task_id, seed=None, criteria=None):
431
+ """
432
+ Build the INTEGRATION test prompt (spec only, no implementation access)
433
+ and call the LLM to generate that test file's source.
434
+
435
+ `criteria` restricts the bar shown to this call. None means the whole set.
436
+ """
437
+ test_agent_md = _load_test_agent_md()
438
+ task = _task_with_criteria(task_id, criteria)
439
+
440
+ prompt = build_integration_test_prompt(test_agent_md, task)
441
+ raw = call_llm("test_integration", prompt, seed=seed)
442
+ return _extract_python(raw)
443
+
444
+
445
+ def agent_generate_system_tests(task_id, seed=None, criteria=None):
446
+ """
447
+ Build the SYSTEM test prompt (spec only, no implementation access) and
448
+ call the LLM to generate that test file's source.
449
+ """
450
+ test_agent_md = _load_test_agent_md()
451
+ task = _task_with_criteria(task_id, criteria)
452
+
453
+ prompt = build_system_test_prompt(test_agent_md, task)
454
+ raw = call_llm("test_system", prompt, seed=seed)
455
+ return _extract_python(raw)
456
+
457
+
458
+ def agent_generate_unit_tests(task_id, seed=None, criteria=None):
459
+ """
460
+ Build the UNIT test prompt and call the LLM to generate that test file's
461
+ source. Unlike the integration/system prompts, this one includes the
462
+ current outputs/agent_src/code/<task_id>/ implementation -- the one
463
+ deliberate exception to keeping test generation blind to the code under
464
+ test, since unit tests need to target the implementation's actual
465
+ functions by name.
466
+ """
467
+ test_agent_md = _load_test_agent_md()
468
+ task = _task_with_criteria(task_id, criteria)
469
+ codebase_text = load_codebase(agent_src_code_path(task_id))
470
+
471
+ prompt = build_unit_test_prompt(test_agent_md, task, codebase_text)
472
+ raw = call_llm("test_unit", prompt, seed=seed)
473
+ return _extract_python(raw)
474
+
475
+
476
+ def _extract_python(text):
477
+ """
478
+ test_agent.md instructs the model to output raw Python source with no
479
+ wrapping. Some models add a ```python fenced block anyway -- strip that
480
+ wrapping the same way _extract_diff() does for PATCH output.
481
+ """
482
+ match = re.search(r"```(?:python)?\s*\n(.*?)(?:```|\Z)", text, re.DOTALL)
483
+ body = match.group(1) if match else text
484
+ return body.strip("\n") + "\n"
485
+
486
+
487
+ def _extract_diff(text):
488
+ """
489
+ code_agent.md instructs the model to wrap the PATCH in a `PATCH:` label and a
490
+ ```diff fenced block. Strip that wrapping so callers get a plain unified diff.
491
+ """
492
+ match = re.search(r"```diff\s*\n(.*?)(?:```|\Z)", text, re.DOTALL)
493
+ body = match.group(1) if match else text
494
+ return body.strip("\n") + "\n"
495
+
496
+
497
+ _HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$")
498
+
499
+
500
+ def _recount_hunk_headers(diff_text):
501
+ """
502
+ LLM-generated diffs frequently have wrong hunk header line counts
503
+ (@@ -old_start,old_count +new_start,new_count @@) even when the hunk
504
+ body itself is correct, which makes `patch`/`git apply` reject an
505
+ otherwise-valid diff. Counting context/added/removed lines is a purely
506
+ mechanical operation, so recompute the counts ourselves rather than
507
+ relying on the model to get the arithmetic right.
508
+ """
509
+ lines = diff_text.splitlines()
510
+ out = []
511
+ i = 0
512
+ while i < len(lines):
513
+ match = _HUNK_HEADER_RE.match(lines[i])
514
+ if not match:
515
+ out.append(lines[i])
516
+ i += 1
517
+ continue
518
+
519
+ old_start, new_start, trailer = match.groups()
520
+ body = []
521
+ j = i + 1
522
+ while j < len(lines) and not lines[j].startswith(("@@ ", "--- ", "+++ ", "diff ")):
523
+ body.append(lines[j])
524
+ j += 1
525
+
526
+ old_count = sum(1 for l in body if l.startswith((" ", "-")))
527
+ new_count = sum(1 for l in body if l.startswith((" ", "+")))
528
+
529
+ out.append(f"@@ -{old_start},{old_count} +{new_start},{new_count} @@{trailer}")
530
+ out.extend(body)
531
+ i = j
532
+
533
+ return "\n".join(out) + "\n"
@@ -0,0 +1,17 @@
1
+ from qikly.agent_api.providers.router import route_model
2
+ from qikly.agent_api.retry import with_retry
3
+
4
+ def call_llm(mode, prompt, seed=None):
5
+ """
6
+ Unified entry point for all LLM calls. Every mode is sent to the single
7
+ provider configured via LLM_PROVIDER (see agent_api/providers/router.py).
8
+ mode: "fix", "patch", "test_integration", "test_system", or "test_unit"
9
+ prompt: fully constructed prompt string
10
+ seed: optional int for best-effort reproducible output
11
+ """
12
+ # Transport failures are retried here; bad answers are not. A rate limit
13
+ # or a gateway error says nothing about the task, and letting it through
14
+ # spends an attempt from the stage budget on a problem the model never
15
+ # saw. A refusal that retrying cannot fix, a depleted balance or a bad
16
+ # key, is raised immediately with its own message intact.
17
+ return with_retry(lambda: route_model(mode, prompt, seed=seed))
@@ -0,0 +1,86 @@
1
+ import os
2
+
3
+ from qikly.agent_api.code_loader.excerpt import excerpt_file, relevant_names
4
+
5
+ def load_codebase(code_dir="outputs/agent_src/code"):
6
+ """
7
+ Load every code file under code_dir and return a single text block, each
8
+ file prefixed with a header so the agent can reference it. code_dir is
9
+ already task-specific (see agent_src_code_path() in agent_interface.py),
10
+ so this never crosses into another task's code -- callers that need the
11
+ whole implementation in context (unit test generation, or PATCH when it
12
+ can't tell which files it needs) use this; PATCH generation itself
13
+ prefers load_target_files() below to avoid paying for files it isn't
14
+ touching.
15
+ """
16
+ output = []
17
+
18
+ for root, dirs, files in os.walk(code_dir):
19
+ dirs[:] = [d for d in dirs if d != "old"]
20
+ for filename in files:
21
+ if filename.endswith(".py"):
22
+ path = os.path.join(root, filename)
23
+ # errors="replace" rather than strict. This reads code the
24
+ # model wrote, and a stray byte in it must not end the run:
25
+ # a decode error here killed one experiment pair outright.
26
+ # The replacement character reaches the prompt, where the
27
+ # FIX/PATCH loop can act on it, which is the whole point of
28
+ # having that loop.
29
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
30
+ content = f.read()
31
+ output.append(f"\n# FILE: {path}\n{content}\n")
32
+
33
+ return "\n".join(output)
34
+
35
+
36
+ def load_target_files(code_dir, target_files, context=None):
37
+ """
38
+ Load just the given files -- as printed in a FIX's target_files list,
39
+ e.g. "outputs/agent_src/code/<task_id>/etl.py" -- instead of every file
40
+ under code_dir. Used to scope the PATCH prompt to what the FIX actually
41
+ said it would touch rather than the whole task codebase.
42
+
43
+ target_files is LLM output, not trusted input: any path that doesn't
44
+ resolve inside code_dir is silently skipped rather than read (no
45
+ escaping the task's own code directory). A path naming a file that
46
+ doesn't exist yet (a FIX about to create it for the first time) is also
47
+ silently skipped -- there's nothing to show the model.
48
+
49
+ `context` is the text that named this work: the FIX itself, and the
50
+ failing test output. Any file over the size threshold is excerpted against
51
+ the identifiers in it, so a large module contributes the definitions this
52
+ failure is about plus a signature for everything else, rather than either
53
+ filling the prompt or being unusable. Passing nothing means every file is
54
+ loaded whole, which is the old behaviour and still right for small ones.
55
+ """
56
+ code_dir_abs = os.path.abspath(code_dir)
57
+ # Names from the FIX and the failure text decide what survives an
58
+ # excerpt. Without context nothing matches, which would collapse every
59
+ # definition, so an empty context means load whole instead.
60
+ wanted = relevant_names(context) if context else None
61
+ output = []
62
+
63
+ for rel_path in target_files:
64
+ candidate = os.path.abspath(rel_path)
65
+ try:
66
+ inside = os.path.commonpath([candidate, code_dir_abs]) == code_dir_abs
67
+ except ValueError:
68
+ # On Windows commonpath raises for paths on different drives. A
69
+ # model that names D:\tmp\x.py should have its suggestion
70
+ # ignored, not end the run with a traceback.
71
+ inside = False
72
+ if not inside:
73
+ continue
74
+ if not os.path.isfile(candidate):
75
+ continue
76
+ if wanted:
77
+ content = excerpt_file(candidate, wanted)
78
+ else:
79
+ # No names to match on, so excerpting would collapse every
80
+ # definition in the file. "I was not told what matters" reads as
81
+ # load it whole.
82
+ with open(candidate, "r", encoding="utf-8", errors="replace") as f:
83
+ content = f.read()
84
+ output.append(f"\n# FILE: {candidate}\n{content}\n")
85
+
86
+ return "\n".join(output)