-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcodegen.py
More file actions
executable file
·1908 lines (1680 loc) · 97 KB
/
Copy pathcodegen.py
File metadata and controls
executable file
·1908 lines (1680 loc) · 97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
codegen.py — an autonomous local coding agent for qwen-fast (Ollama).
The model works in a loop using simple tool blocks (kept simple so a small model
can follow them). After each step the agent feeds tool results back to the model,
and the model continues until it emits <<<DONE>>> (or the iteration cap is hit).
Tools the model can emit:
<<<LIST>>> -> agent replies with the project file tree
<<<READ: path>>> -> agent replies with that file's content
<<<RUN: command>>> -> agent runs it and replies with stdout+stderr
<<<FILE: path>>> ... <<<END>>> -> create/overwrite a file
<<<DONE>>> -> the task is complete; stop
Modes:
python3 codegen.py -d my-app # chat mode (loop per request)
python3 codegen.py -d my-app "task..." # one-shot
Flags:
-d/--dir DIR target directory
-c/--context F preload an existing file as context (repeatable)
-y/--yes auto-approve every action (no prompts)
-n/--max N max agent iterations per request (default 8)
"""
import argparse
import fnmatch
import json
import os
import re
import select
import subprocess
import sys
import uuid
import urllib.error
import urllib.parse
import urllib.request
import contracts # state layer + contract map for --build cross-step debugging
import mem0_tool # local persistent memory (degrades gracefully if mem0ai missing)
import rag_tool # local RAG over your code/docs (degrades gracefully if numpy missing)
OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = os.environ.get("CODEGEN_MODEL", "qwen-fast:latest") # execution model (fast)
# Backend: by default the native local Ollama. Set BASE_URL (an OpenAI-compatible /v1 endpoint) to drive
# the SAME strong harness with a frontier-class CLOUD model cheaply — "Claude-Code power, other price".
BASE_URL = os.environ.get("CODEGEN_BASE_URL", "") # "" => native Ollama; else OpenAI-compatible
API_KEY = os.environ.get("CODEGEN_API_KEY", "") # bearer token for the cloud backend
# Provider presets: name -> (base_url, default_model, api_key_env). Cloud model IDs change fast — override
# with --model if your plan uses a newer one. Cloud endpoints/pricing: see the budget alternatives doc.
PROVIDERS = {
"ollama": ("", "qwen-fast:latest", ""),
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat", "DEEPSEEK_API_KEY"),
"glm": ("https://open.bigmodel.cn/api/paas/v4", "glm-5", "ZAI_API_KEY"),
"kimi": ("https://api.moonshot.ai/v1", "kimi-k2.6", "MOONSHOT_API_KEY"),
"openrouter": ("https://openrouter.ai/api/v1", "deepseek/deepseek-chat", "OPENROUTER_API_KEY"),
"openai": ("https://api.openai.com/v1", "gpt-4o-mini", "OPENAI_API_KEY"),
}
PLANNER_MODEL = os.environ.get("CODEGEN_PLANNER", "") # planning model ("" = use MODEL)
THINK_PLAN = False # use reasoning (think mode) for planning
NUM_CTX = None # runtime context-window override (tokens). None => use the per-model value from
# model_registry (else the legacy 8192). Set by --num-ctx or the GUI's context slider.
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://localhost:8080") # local, private search
SEARCH_RESULTS = 3
WEB_SOURCES_MAX = 6000 # char budget for fetched web sources in Web mode (bigger than MAX_OUT;
# these sources ARE the answer, so they get more of the 8K window)
AUTO_RECALL = True # inject relevant memories before each task (set False to disable)
MAX_OUT = 1500 # truncate tool output fed back to the model
COMPACT_AT = 14 # message count that triggers history compaction (summarize old turns)
KEEP_RECENT = 6 # turns kept verbatim after a compaction
GIT_CHECKPOINT = False # --git: commit after each step so a bad change can be rolled back
DIAGNOSE = False # --learn: study existing code + diagnose BEFORE acting (#8/#9)
CRITIQUE = False # --critique: one self-review pass of proposed files before writing (#5)
REVIEW = False # --review: a reviewer sub-agent gates DONE in a fresh context (#7)
ASSISTANT = False # -a/--ask: general assistant mode (answer in prose, do NOT force file writes)
RAG_DIRS = [] # --rag-dir: extra roots to index for <<<RAGSEARCH>>> (project dir always included)
SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", "__pycache__",
".venv", "venv", "env", "site-packages", ".idea", ".vscode",
".vscode-test", "target", ".mypy_cache", ".pytest_cache", ".cache",
"llama.cpp", ".continue", ".ollama"}
MAX_TREE_FILES = 400 # cap the file tree so a huge dir can't blow the 8K context window
# Block catastrophic shell commands even with -y
DANGER = re.compile(
r"\brm\s+-rf\s+[/~]|\bmkfs|\bdd\s+if=|:\(\)\s*\{|\bchmod\s+-R\s+777\s+/|>\s*/dev/sd",
re.IGNORECASE,
)
SYSTEM_PROMPT = """You are an autonomous coding agent working inside a project directory.
You act step by step using ONLY these tool blocks. After each step you receive the results and continue.
<<<LIST>>> I reply with the project file tree.
<<<READ: path>>> I reply with that file's content.
<<<SEARCH: query>>> I search the web and reply with the top results.
<<<API: url>>> I GET the URL and reply with the body (use for LIVE data: prices, weather, JSON APIs).
<<<RAGSEARCH: query>>> I search YOUR indexed code/docs and reply with the most relevant snippets.
<<<DOCRAG: query>>> I query your ingested DOCUMENTS db (invoices/contracts/bank statements): exact SQL for numbers/sums/filters, hybrid search for content. Use for questions about your real documents, not code.
<<<RESEARCH: query => path>>> I search the web AND write the styled results into <path> (best for fact files).
<<<REMEMBER: fact>>> I save a durable fact (user prefs, project conventions) for the future.
<<<RECALL: query>>> I reply with saved facts relevant to your query.
<<<RUN: command>>> I run it and reply with stdout+stderr.
<<<FILE: relative/path.ext>>>
(full file content)
<<<END>>> I create/overwrite the file.
<<<DONE>>> Emit this when the whole task is complete and verified.
Rules:
- For the CURRENT date/time or any constantly-changing live value, use <<<API: url>>> to a data service (e.g. <<<API: https://timeapi.io/api/Time/current/zone?timeZone=Asia/Jerusalem>>>) — NOT search/research. Web-search results are CACHED and give a stale time. This holds even if the user says "search".
- For a file that must contain CURRENT facts (versions, prices, news), use <<<RESEARCH: query => path>>> — it searches and writes the file in ONE reliable step. Never write such facts from memory.
- ALWAYS write files with <<<FILE>>> blocks containing the FULL code. Never just name a file.
- NEVER emit <<<DONE>>> until you have actually written the required files (and verified by RUN).
- Use EXACTLY three angle brackets. No prose or markdown fences outside blocks.
- Do EXACTLY what is asked — nothing more. Minimal actions.
- Before editing an existing file, READ it first. To match existing code/conventions, use <<<RAGSEARCH: query>>> to find relevant snippets in the project first.
- Paths use forward slashes; parent directories are auto-created.
- Do not install packages or scaffold extra files unless explicitly asked.
Example — task: "create hello.py that prints hello, then run it":
Step 1 you output:
<<<FILE: hello.py>>>
print("hello")
<<<END>>>
<<<RUN: python3 hello.py>>>
Step 2 (after I send you the output) you output:
<<<DONE>>>"""
ASSISTANT_PROMPT = """You are a helpful local assistant. Answer the user directly and clearly in plain prose.
You MAY use these tools when they genuinely help — otherwise just answer:
<<<SEARCH: query>>> web search for facts / docs / news (results are CACHED — never for the current time).
<<<API: url>>> GET a URL for LIVE data (current time, prices, weather, JSON APIs). For the current
time use a time API, e.g. <<<API: https://timeapi.io/api/Time/current/zone?timeZone=Asia/Jerusalem>>> — even if the user says "search".
<<<RAGSEARCH: query>>> search the user's own indexed files/notes.
<<<DOCRAG: query>>> query the user's ingested documents db (invoices/contracts): exact SQL for numbers, hybrid search for content.
<<<RECALL: query>>> recall a saved fact. <<<REMEMBER: fact>>> save a durable fact.
<<<READ: path>>> read a file. <<<RUN: command>>> run a shell command.
Use AT MOST one tool per turn. After tool results come back, WRITE the final answer in prose with NO tool
block. Ground answers in tool results — never invent versions, prices, dates, or facts. Be concise and readable."""
PLANNER_PROMPT = """You are a software planner. Given a task, list the files needed to complete it.
Output ONE line per file in this format:
<relative file path> :: <a precise one-sentence spec of what this file must contain>
Use REAL, sensible project paths (for example: src/models/User.js, src/services/authManager.js, src/app.js).
NEVER output the literal placeholder "path/to" — write actual paths.
Rules:
- Output ONLY these lines. No prose, no code, no markdown.
- Keep the list minimal but complete.
- Order files so dependencies come first (models, then services, then controllers, then routes, then app/server)."""
EXECUTOR_PROMPT = """You write exactly ONE source file. Output ONLY a single block:
<<<FILE: the/path>>>
(full file content)
<<<END>>>
No prose, no markdown fences, no extra files. Write complete, working code that is consistent
with the provided project plan and the already-written files."""
STYLE_PROMPT = """You write a clean, well-formatted markdown document from PROVIDED facts.
Output ONLY the markdown content itself — no <<<FILE>>> blocks, no surrounding code fences, no preamble or sign-off.
Use ONLY the facts you are given. NEVER add a version, number, date, feature, or claim that is not
explicitly present in the facts. If the facts are sparse, keep the document short — do NOT pad it with invented details."""
COMPACT_PROMPT = """You compress a coding agent's conversation into a short progress note.
Capture ONLY: the task/goal, which files were created or changed, key decisions, and what still
remains to do. A few terse bullet lines. Do NOT include full file contents or tool-block syntax."""
DIAGNOSE_PROMPT = """You are a senior engineer writing a quick DIAGNOSIS before any code is written.
Given the task and excerpts of the EXISTING project, output a short briefing — 3 to 6 terse lines:
- Which existing files / patterns / conventions are relevant and should be matched.
- Any assumption that must be verified first (and how to check it).
- A minimal plan: what to create or change.
Do NOT write code. Do NOT use tool blocks. Output ONLY the briefing lines."""
CRITIQUE_PROMPT = """You are a strict reviewer checking a coding agent's PROPOSED files BEFORE they are written.
Given the task and the proposed file(s), list concrete problems: bugs, unmet requirements, wrong APIs, obvious
omissions. Be specific and brief (bullet lines). If the files correctly and completely satisfy the task, reply
with EXACTLY: OK"""
REVIEWER_PROMPT = """You are a meticulous code REVIEWER (read-only) — a sub-agent with a fresh, clean context.
Given the task and the files the agent just wrote, find REAL problems: bugs, unmet requirements, missing error
handling, security issues. List them as short bullets. If the work is correct and complete, reply EXACTLY: LGTM"""
PROJECT_MEMORY = "" # loaded from a project memory file (AGENTS.md) if present
ACTIVE_SKILL = "" # loaded from a skill file via -s/--skill
HOOKS = {} # event automation loaded from .codegen-hooks.json (#6): deny / post_file / post_task
MEMORY_FILES = ("AGENTS.md", ".codegen.md", "CLAUDE.md")
SKILLS_DIR = os.path.expanduser("~/.codegen/skills")
PLUGINS_DIR = os.path.expanduser("~/.codegen/plugins")
HOOKS_FILE = ".codegen-hooks.json"
def load_memory(base):
"""Read a short conventions file (AGENTS.md) from the project, like CLAUDE.md."""
for name in MEMORY_FILES:
p = os.path.join(base, name)
if os.path.isfile(p):
with open(p) as f:
txt = f.read().strip()
print(f"🧠 loaded project memory from {name}", file=sys.stderr)
if len(txt) > 4000:
print(f"⚠️ {name} is large ({len(txt)} chars) — it eats the 8K context. Keep it short.",
file=sys.stderr)
return txt
return ""
def load_skill(name):
"""Read a reusable skill file ~/.codegen/skills/<name>.md (expert instructions for a task type)."""
p = os.path.join(SKILLS_DIR, f"{name}.md")
if os.path.isfile(p):
with open(p) as f:
txt = f.read().strip()
print(f"🎓 loaded skill: {name}", file=sys.stderr)
return txt
avail = [f[:-3] for f in os.listdir(SKILLS_DIR)] if os.path.isdir(SKILLS_DIR) else []
print(f"⚠️ skill '{name}' not found in {SKILLS_DIR}. Available: {avail or '(none)'}", file=sys.stderr)
return ""
def load_hooks(base):
"""Load event-automation hooks from .codegen-hooks.json (#6). Opt-in: active only if present.
Keys: 'deny' (list of glob patterns the agent may NOT write), 'post_file' (shell cmd run after
each write; {file} is substituted), 'post_task' (shell cmd run on DONE; failure blocks DONE and
is fed back so the model fixes it — e.g. tests-before-stop)."""
p = os.path.join(base, HOOKS_FILE)
if not os.path.isfile(p):
return {}
try:
with open(p) as f:
h = json.load(f)
except (OSError, ValueError) as e:
print(f"⚠️ ignoring bad {HOOKS_FILE}: {e}", file=sys.stderr)
return {}
print(f"🪝 loaded hooks from {HOOKS_FILE}: {', '.join(h) or '(empty)'}", file=sys.stderr)
return h
def load_plugin(name):
"""#11 — load a plugin bundle ~/.codegen/plugins/<name>/ that packages, as ONE shareable unit:
skill.md (expert instructions), hooks.json (event automation), and optional AGENTS.md (conventions).
Returns (skill_text, hooks_dict, conventions_text). Lets you ship a workflow as a single folder."""
d = os.path.join(PLUGINS_DIR, name)
if not os.path.isdir(d):
avail = sorted(os.listdir(PLUGINS_DIR)) if os.path.isdir(PLUGINS_DIR) else []
print(f"⚠️ plugin '{name}' not found in {PLUGINS_DIR}. Available: {avail or '(none)'}", file=sys.stderr)
return "", {}, ""
def _read(fname):
p = os.path.join(d, fname)
if os.path.isfile(p):
with open(p) as f:
return f.read().strip()
return ""
skill, conv = _read("skill.md"), _read("AGENTS.md")
hooks = {}
raw = _read("hooks.json")
if raw:
try:
hooks = json.loads(raw)
except ValueError as e:
print(f"⚠️ plugin '{name}': bad hooks.json: {e}", file=sys.stderr)
parts = [n for n, v in (("skill", skill), ("hooks", hooks), ("conventions", conv)) if v]
print(f"🔌 loaded plugin '{name}': {', '.join(parts) or '(empty)'}", file=sys.stderr)
return skill, hooks, conv
def hook_denied(target):
"""Return the deny pattern that blocks writing `target`, or None."""
for pat in HOOKS.get("deny", []):
if fnmatch.fnmatch(target, pat) or fnmatch.fnmatch(os.path.basename(target), pat):
return pat
return None
def run_hook_cmd(cmd, base, timeout=180):
"""Run a hook shell command; return (ok, formatted_output)."""
try:
r = subprocess.run(cmd, shell=True, cwd=base, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return False, f"$ {cmd}\n(timed out after {timeout}s)"
out = (r.stdout + r.stderr).strip() or "(no output)"
return r.returncode == 0, f"$ {cmd}\n(exit {r.returncode})\n{trunc(out)}"
def run_stop_hook(base):
"""post_task hook: run a project check (e.g. tests) before accepting DONE. (ok, report)."""
cmd = HOOKS.get("post_task")
if not cmd:
return True, ""
print(f"🪝 stop hook: {cmd}", file=sys.stderr)
return run_hook_cmd(cmd, base)
def with_memory(prompt):
"""Append the active skill + project memory to a system prompt."""
if ACTIVE_SKILL:
prompt += f"\n\n# Skill — expert instructions for this task (follow precisely):\n{ACTIVE_SKILL}"
if PROJECT_MEMORY:
prompt += f"\n\n# Project conventions (ALWAYS follow these):\n{PROJECT_MEMORY}"
return prompt
# ── tool registry (Open/Closed) ──────────────────────────────────────────────
# One spec per tool block the model can emit. To ADD a tool you append ONE spec
# here and ONE runner in _TOOL_RUNNERS (see do_action) — parse_actions and
# do_action are now data-driven and need no editing. `pattern` is this verb's
# regex alternative; `key` is the named group that, when present in a match,
# identifies the tool; `parse` turns a match into an (kind, target, content)
# action tuple (or None to skip). The combined ACTION_RE is built from these
# patterns, so it stays byte-identical to the hand-written regex it replaced.
def _parse_file(m):
path = clean_path(m.group("fpath"))
return ("file", path, strip_fences(m.group("content"))) if path else None
def _parse_research(m):
rfile = m.group("rfile")
rpath = clean_path(rfile) if rfile else slugify_md(m.group("rquery"))
return ("research", rpath, m.group("rquery").strip())
TOOL_SPECS = [
{"name": "file", "key": "fpath", "parse": _parse_file,
"pattern": r"<<<\s*FILE\s*:\s*(?P<fpath>.+?)\s*>{2,}\s*\n(?P<content>.*?)\n\s*<<<\s*END\s*>*"},
{"name": "run", "key": "cmd", "parse": lambda m: ("run", m.group("cmd").strip(), None),
"pattern": r"<<<\s*RUN\s*:\s*(?P<cmd>.+?)\s*>{2,}"},
{"name": "read", "key": "rpath", "parse": lambda m: ("read", clean_path(m.group("rpath")), None),
"pattern": r"<<<\s*READ\s*:\s*(?P<rpath>.+?)\s*>{2,}"},
{"name": "research", "key": "rquery", "parse": _parse_research,
"pattern": r"<<<\s*RESEARCH\s*:\s*(?P<rquery>.+?)(?:\s*=>\s*(?P<rfile>.+?))?\s*>{2,}"},
{"name": "ragsearch", "key": "ragquery", "parse": lambda m: ("ragsearch", m.group("ragquery").strip(), None),
"pattern": r"<<<\s*RAGSEARCH\s*:\s*(?P<ragquery>.+?)\s*>{2,}"},
{"name": "docrag", "key": "docragquery", "parse": lambda m: ("docrag", m.group("docragquery").strip(), None),
"pattern": r"<<<\s*DOCRAG\s*:\s*(?P<docragquery>.+?)\s*>{2,}"},
{"name": "api", "key": "api", "parse": lambda m: ("api", m.group("api").strip(), None),
"pattern": r"<<<\s*API\s*:\s*(?P<api>.+?)\s*>{2,}"},
{"name": "search", "key": "query", "parse": lambda m: ("search", m.group("query").strip(), None),
"pattern": r"<<<\s*SEARCH\s*:\s*(?P<query>.+?)\s*>{2,}"},
{"name": "remember", "key": "remember", "parse": lambda m: ("remember", m.group("remember").strip(), None),
"pattern": r"<<<\s*REMEMBER\s*:\s*(?P<remember>.+?)\s*>{2,}"},
{"name": "recall", "key": "recall", "parse": lambda m: ("recall", m.group("recall").strip(), None),
"pattern": r"<<<\s*RECALL\s*:\s*(?P<recall>.+?)\s*>{2,}"},
{"name": "list", "key": "list", "parse": lambda m: ("list", None, None),
"pattern": r"<<<\s*(?P<list>LIST)\s*>{2,}"},
{"name": "done", "key": "done", "parse": lambda m: ("done", None, None),
"pattern": r"<<<\s*(?P<done>DONE)\s*>{2,}"},
]
ACTION_RE = re.compile("|".join(s["pattern"] for s in TOOL_SPECS), re.DOTALL | re.IGNORECASE)
def clean_path(p):
return p.strip().strip(">").strip().strip('"\'`').strip()
def slugify_md(q):
"""Derive a default .md filename from a query when the model omits the path."""
s = re.sub(r"[^a-z0-9]+", "-", q.lower()).strip("-")[:40]
return (s or "research") + ".md"
THINK_RE = re.compile(r"<think>.*?</think>\s*", re.DOTALL | re.IGNORECASE)
def strip_think(text):
"""Remove <think>...</think> reasoning that thinking models (qwen3, abliterated variants) emit
inside the content. If a <think> was left unclosed (output cut mid-reasoning), drop from it to the
end — there is no real answer after it. Keeps the harness readable and saves context."""
text = THINK_RE.sub("", text)
low = text.lower()
i = low.rfind("<think>")
if i != -1 and "</think>" not in low[i:]: # unclosed → everything after is reasoning, not answer
text = text[:i]
return text.strip()
def strip_fences(content):
lines = content.split("\n")
if lines and lines[0].lstrip().startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
return "\n".join(lines)
def parse_actions(text):
actions = []
for m in ACTION_RE.finditer(text):
for spec in TOOL_SPECS: # exactly one spec's key group is set per match
if m.group(spec["key"]) is not None:
act = spec["parse"](m)
if act:
actions.append(act)
break
# Fallback: recover an unclosed trailing FILE block (model forgot <<<END>>>)
if not any(a[0] == "file" for a in actions):
m = re.search(r"<<<\s*FILE\s*:\s*(.+?)\s*>{2,}\s*\n(.*)", text, re.DOTALL | re.IGNORECASE)
if m:
path = clean_path(m.group(1))
content = re.split(r"\n\s*<<<\s*END", m.group(2))[0]
if path:
actions.append(("file", path, strip_fences(content)))
return actions
def project_tree(base):
"""List the project's files, but bounded: skip env/cache/vendor dirs and hidden
dirs, and cap the count. A huge dir (e.g. ~/Downloads with .venv + GGUF models)
would otherwise serialize to hundreds of thousands of tokens and blow the 8K window."""
out = []
capped = False
for root, dirs, files in os.walk(base):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
for f in files:
out.append(os.path.relpath(os.path.join(root, f), base))
if len(out) > MAX_TREE_FILES * 5: # stop walking a massive tree early
capped = True
break
if capped:
break
out.sort()
if len(out) > MAX_TREE_FILES:
out = out[:MAX_TREE_FILES]
capped = True
tree = "\n".join(out) or "(empty project)"
if capped:
tree += ("\n...[tree truncated — too many files. Work in a smaller project "
"directory for best results.]")
return tree
def trunc(s):
return s if len(s) <= MAX_OUT else s[:MAX_OUT] + f"\n...[truncated {len(s) - MAX_OUT} chars]"
def http_get(url):
"""#10 — GET a URL for LIVE data (prices, weather, JSON APIs). Returns the body, pretty-printed
if it's JSON. Use this instead of web search when freshness matters (search snippets are cached)."""
url = url.strip().strip('"').strip("'").strip()
if not url.lower().startswith(("http://", "https://")):
url = "https://" + url
try:
req = urllib.request.Request(
url, headers={"User-Agent": "codegen-agent", "Accept": "application/json, text/plain, */*"})
with urllib.request.urlopen(req, timeout=20) as r:
body = r.read().decode("utf-8", "ignore")
except Exception as e:
return f"API GET failed: {e}"
try:
return json.dumps(json.loads(body), indent=2, ensure_ascii=False)
except ValueError:
return body
def search_results(query, n=SEARCH_RESULTS):
"""Return up to n results as [{title,url,content}] via SearXNG (private), then DuckDuckGo.
Empty list on failure. This is the structured source both web_search() and web_answer() use."""
query = query.strip().strip('"').strip("'").strip() # quotes break search (exact-match → 0 results)
# 1) SearXNG — local, private, JSON API
try:
url = f"{SEARXNG_URL}/search?q={urllib.parse.quote(query)}&format=json"
req = urllib.request.Request(url, headers={"User-Agent": "codegen-agent"})
with urllib.request.urlopen(req, timeout=20) as r:
data = json.loads(r.read())
res = data.get("results", [])[:n]
if res:
return [{"title": x.get("title", "").strip(), "url": x.get("url", ""),
"content": x.get("content", "").strip()} for x in res]
except Exception:
pass
# 2) DuckDuckGo lite fallback — no server needed, stable HTML
try:
url = "https://lite.duckduckgo.com/lite/?q=" + urllib.parse.quote(query)
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=20) as r:
html = r.read().decode("utf-8", "ignore")
links = re.findall(r'href="(//duckduckgo\.com/l/\?uddg=[^"]+)"[^>]*>(.*?)</a>', html, re.DOTALL)
snips = re.findall(r'result-snippet[^>]*>(.*?)</td>', html, re.DOTALL)
out = []
for i, (href, title) in enumerate(links[:n]):
mm = re.search(r"uddg=([^&]+)", href)
out.append({"title": re.sub("<.*?>", "", title).strip(),
"url": urllib.parse.unquote(mm.group(1)) if mm else href,
"content": re.sub("<.*?>", "", snips[i]).strip() if i < len(snips) else ""})
return out
except Exception:
return []
def web_search(query):
"""Formatted top results (titles+urls+snippets) — used by the SEARCH and RESEARCH tools."""
res = search_results(query)
if not res:
return f"Search failed or no results (SearXNG at {SEARXNG_URL} / DuckDuckGo unreachable)."
return "\n\n".join(f"{i+1}. {x['title']}\n {x['url']}\n {x['content']}" for i, x in enumerate(res))
def fetch_page_text(url, limit=2500):
"""Fetch a page and return readable text (tags/scripts/boilerplate stripped), truncated.
Fresher and richer than a cached search snippet — this is what makes 'search the internet'
reliable. Drops nav/header/footer/aside/forms so the model sees content, not chrome."""
raw = http_get(url)
if raw.startswith("API GET failed"):
return ""
# Drop whole non-content regions (scripts, styles, and page chrome).
html = re.sub(r"(?is)<(script|style|noscript|head|nav|footer|header|aside|form|svg|button)[^>]*>.*?</\1>",
" ", raw)
text = re.sub(r"(?s)<[^>]+>", " ", html) # drop remaining tags
text = re.sub(r"&[a-z#0-9]+;", " ", text) # crude entity strip
text = re.sub(r"\s+", " ", text).strip()
return text[:limit]
WEB_ANSWER_PROMPT = """You answer the user's question using ONLY the SOURCES provided below.
Rules:
- Reply in ENGLISH.
- Use ONLY facts present in the sources. NEVER add a number, date, version, price, or claim from your own memory.
- If the sources do not contain the answer, say exactly: "The sources don't contain this."
- Be concise. Then add a final line: "Sources:" listing the source numbers you used.
- Do NOT output any tool blocks (<<<...>>>). Just the prose answer."""
def _api_json(url, timeout=15):
"""GET and parse JSON, or None. For the live-data sources below."""
try:
req = urllib.request.Request(url, headers={"User-Agent": "codegen-agent",
"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", "ignore"))
except Exception:
return None
def geocode_place(place):
"""Place name -> {name,country,lat,lon,timezone} via Open-Meteo geocoding (free, no key)."""
if not place:
return None
url = ("https://geocoding-api.open-meteo.com/v1/search?count=1&language=en&format=json&name="
+ urllib.parse.quote(place))
res = (_api_json(url) or {}).get("results") or []
if not res:
return None
g = res[0]
return {"name": g.get("name"), "country": g.get("country"), "lat": g.get("latitude"),
"lon": g.get("longitude"), "timezone": g.get("timezone")}
def live_time(place):
"""(fact, source) for the CURRENT time at a place, or None. Geocode -> timezone -> clock API."""
g = geocode_place(place)
if not g or not g.get("timezone"):
return None
d = _api_json("https://timeapi.io/api/Time/current/zone?timeZone=" + urllib.parse.quote(g["timezone"]))
if not d or "time" not in d:
return None
loc = ", ".join(x for x in (g.get("name"), g.get("country")) if x)
return (f"Current local time in {loc} ({g['timezone']}): {d.get('time')} on {d.get('date')} "
f"({d.get('dayOfWeek')}).", f"https://timeapi.io — timezone {g['timezone']}")
_WCODE = {0: "clear sky", 1: "mainly clear", 2: "partly cloudy", 3: "overcast", 45: "fog",
48: "rime fog", 51: "light drizzle", 53: "drizzle", 55: "dense drizzle", 56: "freezing drizzle",
61: "light rain", 63: "rain", 65: "heavy rain", 66: "freezing rain", 71: "light snow",
73: "snow", 75: "heavy snow", 77: "snow grains", 80: "rain showers", 81: "rain showers",
82: "violent rain showers", 85: "snow showers", 86: "snow showers", 95: "thunderstorm",
96: "thunderstorm with hail", 99: "thunderstorm with heavy hail"}
def live_weather(place):
"""(fact, source) for CURRENT weather at a place, or None. Geocode -> Open-Meteo forecast."""
g = geocode_place(place)
if not g or g.get("lat") is None:
return None
url = (f"https://api.open-meteo.com/v1/forecast?latitude={g['lat']}&longitude={g['lon']}"
"¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m")
cur = (_api_json(url) or {}).get("current")
if not cur:
return None
loc = ", ".join(x for x in (g.get("name"), g.get("country")) if x)
desc = _WCODE.get(cur.get("weather_code"), "")
return (f"Current weather in {loc}: {cur.get('temperature_2m')}°C (feels like "
f"{cur.get('apparent_temperature')}°C), {desc}, humidity {cur.get('relative_humidity_2m')}%, "
f"wind {cur.get('wind_speed_10m')} km/h.", "https://open-meteo.com")
def live_version(software):
"""(fact, source) for the latest release of a software via the GitHub API, or None.
Resolves the repo by search (top by stars), then releases/latest, then tags as a fallback.
The repo URL is returned as the source so a wrong-repo guess is visible/verifiable."""
if not software:
return None
sr = _api_json("https://api.github.com/search/repositories?per_page=1&sort=stars&q="
+ urllib.parse.quote(software))
items = (sr or {}).get("items") or []
if not items:
return None
repo = items[0].get("full_name")
rel = _api_json(f"https://api.github.com/repos/{repo}/releases/latest")
tag = (rel or {}).get("tag_name")
if not tag: # repo may publish tags without GitHub "releases"
tags = _api_json(f"https://api.github.com/repos/{repo}/tags?per_page=1")
tag = tags[0].get("name") if tags else None
if not tag:
return None
return (f"Latest release of {repo}: {tag}.", f"https://github.com/{repo}/releases")
def live_currency(base, quote):
"""(fact, source) for an exchange rate, or None. Free, no key."""
base = (base or "USD").upper()
quote = (quote or "ILS").upper()
d = _api_json("https://open.er-api.com/v6/latest/" + urllib.parse.quote(base))
rate = ((d or {}).get("rates") or {}).get(quote)
if rate is None:
return None
return (f"1 {base} = {rate} {quote} (updated {(d or {}).get('time_last_update_utc', 'recently')}).",
"https://open.er-api.com")
CLASSIFY_PROMPT = """Classify the user's question for a live-data lookup. Output ONE line of JSON, nothing else:
{"type":"time|weather|currency|version|general","place":"<city or country in ENGLISH, else empty>","base":"<3-letter currency or empty>","quote":"<3-letter currency or empty>","software":"<software/tool name, else empty>"}
- "time": asks the current time/date somewhere.
- "weather": asks current weather/temperature somewhere.
- "currency": asks an exchange rate (how much one currency is in another).
- "version": asks the latest/current version of a software, tool, library, or app.
- "general": ANYTHING else (facts, news, product prices, definitions...).
Translate the place to its common ENGLISH name. Output ONLY the JSON object."""
def classify_query(query):
"""Ask the model ONLY to parse intent (it is good at this) -> dict. Harness fetches the facts."""
try:
raw = ask_model([{"role": "system", "content": CLASSIFY_PROMPT},
{"role": "user", "content": query}])
except Exception:
return {"type": "general"}
m = re.search(r"\{.*\}", raw, re.DOTALL)
if not m:
return {"type": "general"}
try:
d = json.loads(m.group(0))
return d if isinstance(d, dict) else {"type": "general"}
except ValueError:
return {"type": "general"}
ANSWER_FROM_FACT_PROMPT = """Answer the user's question using ONLY the FACT provided.
Reply in ENGLISH, in one or two clear sentences. Use ONLY the given fact — never add or
change any number, date, or detail. Do not output tool blocks."""
def web_answer(query, emit=None, should_stop=None):
"""Smart grounded answer for ANY web-needing question. Classifies intent, then the harness
fetches from the RIGHT source (live APIs for time/weather/currency, else search+page-fetch),
and the model only phrases the result. Facts come from the script, never the model's memory."""
emit_event(emit, {"type": "thinking"})
intent = classify_query(query)
typ = (intent.get("type") or "general").lower()
place = (intent.get("place") or "").strip()
fact = source = None
if typ == "time":
emit_event(emit, {"type": "action", "kind": "api", "label": f"live time: {place or '?'}"})
r = live_time(place)
if r:
fact, source = r
elif typ == "weather":
emit_event(emit, {"type": "action", "kind": "api", "label": f"live weather: {place or '?'}"})
r = live_weather(place)
if r:
fact, source = r
elif typ == "currency":
emit_event(emit, {"type": "action", "kind": "api", "label": "live exchange rate"})
r = live_currency(intent.get("base"), intent.get("quote"))
if r:
fact, source = r
elif typ == "version":
sw = (intent.get("software") or "").strip()
emit_event(emit, {"type": "action", "kind": "api", "label": f"latest version: {sw or '?'}"})
r = live_version(sw)
if r:
fact, source = r
if fact:
emit_event(emit, {"type": "result", "text": fact})
emit_event(emit, {"type": "thinking"})
stream_cb = (lambda t: emit_event(emit, {"type": "token", "text": t})) if emit else None
ans = ask_model([
{"role": "system", "content": with_memory(ANSWER_FROM_FACT_PROMPT)},
{"role": "user", "content": f"Question: {query}\n\nFACT: {fact}\n\nAnswer now."},
], stream_cb=stream_cb, should_stop=should_stop).strip()
full = f"{ans}\n\n— source —\n {source}"
emit_event(emit, {"type": "assistant", "text": full})
print(ans)
return full
# general questions (and any failed live lookup) -> search + fetch pages + grounded answer
return _web_search_answer(query, emit, should_stop)
def _web_search_answer(query, emit=None, should_stop=None):
"""General grounded pipeline: search -> fetch the top pages -> answer constrained to those
sources -> cite them. The model only phrases; it cannot hallucinate facts."""
emit_event(emit, {"type": "action", "kind": "search", "label": query})
results = search_results(query, n=5)
if not results:
msg = "No web results (is SearXNG running, or is the network up?)."
emit_event(emit, {"type": "assistant", "text": msg})
return msg
# Fetch full text of the top 3 pages for freshness (authoritative sources like GitHub
# releases are often #3); fall back to snippets for the rest. Per-page text is capped so
# three pages fit the 8K window; the combined blob has its own (larger) budget than MAX_OUT.
sources = []
for i, r in enumerate(results):
body = r["content"]
if i < 3 and r["url"]:
if should_stop and should_stop():
break
emit_event(emit, {"type": "action", "kind": "api", "label": f"fetch {r['url'][:60]}"})
page = fetch_page_text(r["url"], limit=2000)
if page:
body = page
sources.append({"n": i + 1, "title": r["title"], "url": r["url"], "body": body})
blob = "\n\n".join(f"[{s['n']}] {s['title']} — {s['url']}\n{s['body']}" for s in sources)
if len(blob) > WEB_SOURCES_MAX: # bigger budget than MAX_OUT — these sources ARE the answer
blob = blob[:WEB_SOURCES_MAX] + "\n...[sources truncated]"
emit_event(emit, {"type": "thinking"})
stream_cb = (lambda t: emit_event(emit, {"type": "token", "text": t})) if emit else None
answer = ask_model([
{"role": "system", "content": with_memory(WEB_ANSWER_PROMPT)},
{"role": "user", "content": f"Question: {query}\n\nSOURCES:\n{blob}\n\nAnswer now."},
], stream_cb=stream_cb, should_stop=should_stop).strip()
links = "\n".join(f" [{s['n']}] {s['url']}" for s in sources if s["url"])
full = f"{answer}\n\n— retrieved sources —\n{links}" if links else answer
emit_event(emit, {"type": "assistant", "text": full})
print(answer)
return full
RESEARCH_PLAN_PROMPT = """You are a critical research planner. Given a topic, claim, or source, output a list of
focused web-search questions that TOGETHER uncover the full picture — and that CHALLENGE the claim, not only confirm it.
Cover, across the questions: facts/numbers to verify, who is behind it (company/author/funding/incentives/sponsor),
real costs, objective performance vs alternatives, risks (security/privacy/legal/regulatory), competitors, and what
the source likely leaves out.
REQUIRED — write SHORT KEYWORD QUERIES the way a person types into Google (3-7 words), NOT full questions or
sentences. Example GOOD: "Oracle free tier account banned reddit". Example BAD: "What are the complaints about
Oracle Cloud Free Tier VPS for June 2026?".
- At least 2 queries MUST be ADVERSARIAL — hunt for problems: e.g. "<thing> problems reddit", "<thing> scam",
"<thing> account banned", "<thing> downgrade".
- Add a YEAR to AT MOST one or two queries that are about current specs/limits/prices (use a bare year like
"2026", NEVER a month like "June 2026"). Keep the other queries broad and year-free so they actually return results.
Output EXACTLY 6 queries, ONE per line, no numbering and no prose. Each a standalone web-search query in English."""
RESEARCH_FINDING_PROMPT = """Answer the sub-question from the SOURCES. Report what they actually say — BOTH kinds:
- CONCRETE facts: numbers, dates, versions, prices — quote them and the source URL.
- QUALITATIVE facts: documented problems, complaints, policies, behaviors — these matter just as much in a critical
investigation. Attribute them, e.g. "multiple users on r/oraclecloud report Oracle deleted free-tier accounts
without warning" or "the docs state idle instances are reclaimed". A named, sourced complaint IS a valid finding.
STAY ON SUBJECT: only facts about the INVESTIGATION SUBJECT below. If a number is for a DIFFERENT product/hardware
(e.g. a different GPU/computer), DO NOT use it — a real number from the wrong context is worse than no answer.
CROSS-CHECK: prefer facts in 2+ sources; mark a lone claim "[single source]"; if sources DISAGREE, report both.
DATES: include "as of <date>" when a source gives one — recency matters.
2-4 sentences. Reply EXACTLY "No data found." ONLY if the sources truly say nothing relevant to the sub-question.
Reply in English. NEVER invent or estimate. No tool blocks."""
RESEARCH_GAP_PROMPT = """You review research findings and name what is still MISSING for a complete, critical picture.
Output up to 3 web-search questions for IMPORTANT facts not yet answered, or single-source claims that need
cross-checking against another source. If the findings already cover the topic well, output exactly: NONE.
One question per line, no numbering, no prose. English."""
RESEARCH_VERIFY_PROMPT = """You are a fact-checker. You are given a DRAFT report and the FINDINGS it was based on.
Return a corrected version of the report where:
- Every claim that is NOT directly supported by a finding is removed, or softened to "could not verify".
- Any number/date/name that does not appear in the findings is deleted.
- Generic boilerplate not grounded in a finding (e.g. vague risks) is removed.
- Everything supported by the findings is KEPT, with its [n] citation.
Keep the same section structure and English. Output ONLY the corrected report."""
RESEARCH_SYNTH_PROMPT = """You are a critical research assistant. Using ONLY the FINDINGS below (each grounded in web
sources), write an investigation report in English. Be direct and fact-based, not condescending; present the strengths
of what you criticize; flag uncertainty honestly.
HARD RULES:
- EVIDENCE OR SILENCE: only state that a claim is wrong/outdated if a FINDING gives a SPECIFIC contradicting fact —
quote that number/date. If you cannot verify a claim from the findings, write "could not verify" — do NOT assert it.
- NO FILLER: if a section has no grounded data, write exactly "(no data found)". Never write generic boilerplate
(e.g. vague "security/privacy/legal risks" that aren't in the findings).
- Prefer concrete numbers/names/dates from the findings over general statements.
- Never invent a fact that is not in the findings.
Structure:
1. Title — the REAL conclusion after investigating (not a summary of the source)
2. Data corrections — figures/claims in the source the findings confirm or contradict (quote the contradicting fact)
3. Who is behind it — the creator/author of the ORIGINAL source. NEVER name a website you searched
(a benchmark site, blog, or search result) as the author. If the creator is not identified in the
findings or original source, write exactly: "The source's author is not identified in the findings."
4. Real costs / hardware (if relevant)
5. Risks — only specific ones the findings surface
6. What was NOT said — blind spots, omissions, unfair comparisons
7. Broad context — competitors, alternative approaches
8. Bottom line — honest, even if it contradicts the source
Cite source numbers like [n] from the findings."""
def research_pipeline(topic, emit=None, should_stop=None, max_q=6):
"""Critical multi-step research that a SMALL model can actually run: the harness plans
sub-questions, runs a grounded web search for each, then the model synthesizes a report.
Every model call is small and grounded — no single call has to 'be' a frontier researcher."""
import datetime
today = datetime.date.today().strftime("%B %Y") # recency anchor for time-sensitive queries
subject = " ".join(topic.split())[:200] # short subject string → keeps findings on-topic
findings, src_list = [], []
# one grounded finding for a question: search → fetch top 2 pages → extract on-subject facts.
def do_finding(n, q):
if should_stop and should_stop():
return None
emit_event(emit, {"type": "action", "kind": "search", "label": f"{n}: {q}"})
results = search_results(q, n=4)
if not results:
return None
parts = [] # full text of top 2 pages (numbers live in the page, not the snippet) + snippets
for k, r in enumerate(results, 1):
text = r["content"]
if k <= 2 and r["url"]:
page = fetch_page_text(r["url"], limit=1500)
if page:
text = page
parts.append(f"[{n}.{k}] {r['title']} — {r['url']}\n{text}")
emit_event(emit, {"type": "thinking"})
finding = ask_model([
{"role": "system", "content": RESEARCH_FINDING_PROMPT},
{"role": "user", "content": f"INVESTIGATION SUBJECT: {subject}\nToday is {today}.\n\n"
f"Sub-question: {q}\n\nSOURCES:\n{chr(10).join(parts)[:4500]}\n\n"
f"Answer (stay on subject; cross-check across sources)."},
], should_stop=should_stop).strip()
src_list.extend(r["url"] for r in results if r["url"])
emit_event(emit, {"type": "result", "text": f"[{n}] {q}\n{finding}"})
return {"n": n, "q": q, "finding": finding}
# 0) ORIGINAL SOURCE — if the topic has a URL, fetch it so "who's behind it" / "what was said"
# are grounded in the actual source, not guessed.
original = ""
mu = re.search(r"https?://[^\s)\]]+", topic)
if mu:
emit_event(emit, {"type": "action", "kind": "api", "label": f"fetch original: {mu.group(0)[:50]}"})
page = fetch_page_text(mu.group(0), limit=1800)
if page:
original = f"ORIGINAL SOURCE ({mu.group(0)}):\n{page}"
# 1) PLAN — decompose into challenging, adversarial, recency-aware sub-questions (one model call).
emit_event(emit, {"type": "action", "kind": "plan", "label": "planning research questions"})
emit_event(emit, {"type": "thinking"})
year = today.split()[-1] # bare year only — passing the month made the model glue "June 2026" onto queries
raw = ask_model([
{"role": "system", "content": with_memory(RESEARCH_PLAN_PROMPT)},
{"role": "user", "content": f"Current year: {year}.\nTopic / source:\n{trunc(topic)}\n\n"
f"List the short keyword search queries."},
], should_stop=should_stop)
questions = [re.sub(r"^[\-\*\d\.\)\s]+", "", ln).strip() for ln in raw.splitlines() if ln.strip()]
questions = [q for q in questions if len(q) > 8][:max_q]
if not questions:
questions = [topic.strip()[:120]]
emit_event(emit, {"type": "assistant",
"text": "🔬 Research plan:\n" + "\n".join(f"• {q}" for q in questions)})
# 2) RETRIEVE — grounded finding per sub-question.
for i, q in enumerate(questions, 1):
if should_stop and should_stop():
emit_event(emit, {"type": "assistant", "text": "⏹️ Stopped."})
break
f = do_finding(i, q)
if f:
findings.append(f)
# 2b) GAP-FILLING ROUND — ask what's still missing / needs cross-checking, then chase those too.
if findings and not (should_stop and should_stop()):
fblob0 = "\n\n".join(f"[{f['n']}] {f['q']}\n{f['finding']}" for f in findings)
emit_event(emit, {"type": "action", "kind": "plan", "label": "finding gaps to fill"})
emit_event(emit, {"type": "thinking"})
gap_raw = ask_model([
{"role": "system", "content": RESEARCH_GAP_PROMPT},
{"role": "user", "content": f"TOPIC: {subject}\n\nFINDINGS SO FAR:\n{fblob0[:4000]}\n\n"
f"What is still missing? (up to 3, or NONE)"},
], should_stop=should_stop)
gaps = [re.sub(r"^[\-\*\d\.\)\s]+", "", ln).strip() for ln in gap_raw.splitlines() if ln.strip()]
gaps = [g for g in gaps if len(g) > 8 and not g.upper().startswith("NONE")][:3]
for j, g in enumerate(gaps, len(findings) + 1):
if should_stop and should_stop():
break
f = do_finding(j, g)
if f:
findings.append(f)
if not findings:
msg = "No findings — is SearXNG running / network up?"
emit_event(emit, {"type": "assistant", "text": msg})
return msg
if should_stop and should_stop():
return ""
# 3) SYNTHESIZE — a grounded draft report from the collected findings (+ the original source).
emit_event(emit, {"type": "action", "kind": "plan", "label": "synthesizing report"})
emit_event(emit, {"type": "thinking"})
fblob = "\n\n".join(f"[{f['n']}] Q: {f['q']}\n{f['finding']}" for f in findings)
orig_block = f"\n\nORIGINAL SOURCE (what the source itself says):\n{original[:1500]}" if original else ""
stream_cb = (lambda t: emit_event(emit, {"type": "token", "text": t})) if emit else None
draft = ask_model([
{"role": "system", "content": with_memory(RESEARCH_SYNTH_PROMPT)},
{"role": "user", "content": f"TOPIC:\n{topic[:800]}{orig_block}\n\nFINDINGS:\n{fblob[:4500]}\n\nWrite the report."},
], should_stop=should_stop).strip()
# 4) VERIFY (CRAG-style fact-check) — drop any claim not supported by the findings; stream the
# corrected version as the final answer. This is the span-level verification step.
report = draft
if draft and not (should_stop and should_stop()):
emit_event(emit, {"type": "action", "kind": "plan", "label": "fact-checking the report"})
emit_event(emit, {"type": "thinking"})
report = ask_model([
{"role": "system", "content": RESEARCH_VERIFY_PROMPT},
{"role": "user", "content": f"FINDINGS:\n{fblob[:4500]}\n\nDRAFT REPORT:\n{draft}\n\n"
f"Return the corrected report."},
], stream_cb=stream_cb, should_stop=should_stop).strip() or draft
seen, links = set(), []
for u in src_list:
if u not in seen:
seen.add(u)
links.append(u)
full = report + "\n\n— sources —\n" + "\n".join(f" • {u}" for u in links[:12])
emit_event(emit, {"type": "assistant", "text": full})
print(report)
return full
def _ollama_chat(messages, model, think, stream_cb=None, should_stop=None,
num_ctx=8192, temperature=0.2):
"""Native local Ollama backend (default). If stream_cb is given, stream tokens to it
(used by the server UI so the slow CPU model 'feels alive') and return the full text.
`num_ctx`/`temperature` are sent as per-request Ollama options. Per-request
options.num_ctx OVERRIDES the Modelfile default (Ollama API), so the context window
is now runtime-configurable from the CLI/GUI with NO Modelfile rebuild. Defaults match
the value codegen used historically, so behavior is unchanged unless overridden.
We ALWAYS request a streamed response — even when stream_cb is None — so should_stop()
is polled per line on every call. Otherwise a non-streaming urlopen blocks up to 600s
and ignores Stop, leaving the run 'active' and refusing new tasks (the classic
'a run is already active' lockup)."""
payload = {"model": model, "stream": True, "messages": messages,
"think": think, # False = fast; True = reasoning (used only for planning)
"options": {"num_ctx": num_ctx, "temperature": temperature}}
req = urllib.request.Request(OLLAMA_URL, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
parts = []
with urllib.request.urlopen(req, timeout=600) as resp:
for line in resp: # Ollama streams one JSON object per line
if should_stop and should_stop():
break # abort generation immediately on Stop
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except ValueError:
continue
tok = obj.get("message", {}).get("content", "")
if tok:
parts.append(tok)
if stream_cb:
stream_cb(tok)
if obj.get("done"):
break
return "".join(parts)
def _openai_chat(messages, model, stream_cb=None, should_stop=None, temperature=0.2):
"""OpenAI-compatible cloud backend (DeepSeek / GLM / Kimi / OpenRouter / OpenAI / Ollama-/v1).
This is how the strong local harness borrows a frontier-class brain cheaply."""
# Always stream so should_stop() is polled per line on every call (see _ollama_chat).
payload = {"model": model, "messages": messages, "stream": True, "temperature": temperature}
headers = {"Content-Type": "application/json"}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
req = urllib.request.Request(BASE_URL.rstrip("/") + "/chat/completions",
data=json.dumps(payload).encode(), headers=headers)
parts = []
with urllib.request.urlopen(req, timeout=600) as resp:
for line in resp: # SSE: "data: {json}" lines, terminated by "data: [DONE]"
if should_stop and should_stop():
break # abort generation immediately on Stop
line = line.strip()
if not line.startswith(b"data:"):
continue
chunk = line[5:].strip()
if chunk == b"[DONE]":
break
try:
obj = json.loads(chunk)
except ValueError:
continue