cybercode-cli 1.0.0

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.
@@ -0,0 +1,1029 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ agent_core — a self-contained minimal autonomous agent.
5
+
6
+ This module implements the core of an autonomous agent: an LLM client
7
+ (OpenAI-compatible, streaming, with function-calling), a ~100-line agent
8
+ loop, and 9 atomic tools for system-level control (code execution, file
9
+ I/O, web fetching, user interaction, and memory management).
10
+
11
+ The architecture and design philosophy are inspired by and reference the
12
+ GenericAgent project (https://github.com/lsdefine/GenericAgent), which is
13
+ licensed under the MIT License. See the LICENSE file and README for full
14
+ attribution.
15
+
16
+ Dependencies: only the Python standard library + `requests` (already a
17
+ dependency of most agent setups). No LangChain, no Playwright, no browser
18
+ binaries.
19
+
20
+ Usage:
21
+ from agent_core import Agent
22
+ agent = Agent()
23
+ import threading; threading.Thread(target=agent.run, daemon=True).start()
24
+ dq = agent.put_task("Hello, what can you do?")
25
+ while True:
26
+ item = dq.get()
27
+ if "done" in item: print(item["done"]); break
28
+ if "next" in item: print(item["next"], end="")
29
+ """
30
+ import json
31
+ import os
32
+ import queue
33
+ import re
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+ import threading
38
+ import time
39
+ import traceback
40
+ from dataclasses import dataclass, field
41
+ from datetime import datetime
42
+ from pathlib import Path
43
+ from typing import Any, Optional
44
+
45
+ try:
46
+ import requests
47
+ except ImportError:
48
+ requests = None # web tools will degrade gracefully
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # License attribution
52
+ # ---------------------------------------------------------------------------
53
+ # Portions of this agent core (the agent loop structure, the 9-tool design,
54
+ # the memory layering concept, and the system prompt philosophy) are derived
55
+ # from GenericAgent by lsdefine, licensed under the MIT License:
56
+ #
57
+ # Copyright (c) 2025 lsdefine
58
+ # https://github.com/lsdefine/GenericAgent
59
+ #
60
+ # See LICENSE for the full MIT text.
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Configuration
64
+ # ---------------------------------------------------------------------------
65
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
66
+ ROOT_DIR = SCRIPT_DIR
67
+ TEMP_DIR = os.path.join(SCRIPT_DIR, "temp")
68
+ MEMORY_DIR = os.path.join(SCRIPT_DIR, "memory")
69
+ os.makedirs(TEMP_DIR, exist_ok=True)
70
+ os.makedirs(MEMORY_DIR, exist_ok=True)
71
+
72
+ # Ensure memory files exist
73
+ _MEM_FILES = {
74
+ "global_mem.txt": "# [Global Memory - L2]\n",
75
+ "global_mem_insight.txt": "# [Global Memory Insight - L1]\nRead global_mem.txt for L2 facts.\nL2: currently empty\nL3: (none yet)\n",
76
+ }
77
+ for _f, _default in _MEM_FILES.items():
78
+ _p = os.path.join(MEMORY_DIR, _f)
79
+ if not os.path.exists(_p):
80
+ with open(_p, "w", encoding="utf-8") as fh:
81
+ fh.write(_default)
82
+
83
+ FILE_HINT = "If you need to show files to user, use [FILE:filepath] in your response."
84
+
85
+ SYSTEM_PROMPT = """# Role: Physical-Level Omnipotent Executor
86
+ You have full physical access: file I/O, script execution, web fetching, and system-level intervention. Never deflect with "can't do it" — don't speculate, use tools to probe.
87
+ Summarize and reply in the user's language or follow the user's prompt.
88
+
89
+ ## Action Principles
90
+ Before each tool call, reason about: current phase, whether the last result met expectations, and next strategy. Include a <summary> in the reply text of each turn.
91
+ - Probe first: on failure, gather sufficient info (logs/status/context), store key findings in working memory, then decide to retry or pivot. Ask the user before irreversible operations.
92
+ - Failure escalation: 1st fail → read error and understand cause; 2nd → probe environment state; 3rd → deep analysis then switch approach or ask user. Never repeat an action without new information.
93
+
94
+ ## Memory
95
+ - L0: Meta rules (this prompt)
96
+ - L1: ../memory/global_mem_insight.txt (minimal index)
97
+ - L2: ../memory/global_mem.txt (stable facts)
98
+ - L3: ../memory/*.md (task SOPs)
99
+ - Use update_working_checkpoint for short-term notes during long tasks.
100
+ - Use start_long_term_update when a task completes and has lessons worth saving.
101
+
102
+ ## Constitution
103
+ 1. Execute step by step, control granularity, limit blast radius; request intervention after 3 failures.
104
+ 2. Check memory before decisions; always use existing SOPs; revisit on repeated failures.
105
+ 3. Key/secret files: reference only, never read or move.
106
+ 4. Files under memory/ should be patched (not overwritten) unless creating new ones.
107
+ """
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Tool schema (OpenAI function-calling format)
111
+ # ---------------------------------------------------------------------------
112
+ TOOLS_SCHEMA = [
113
+ {"type": "function", "function": {
114
+ "name": "code_run",
115
+ "description": "Code executor. Prefer python. Runs Python scripts or bash/shell commands.",
116
+ "parameters": {"type": "object", "properties": {
117
+ "script": {"type": "string", "description": "The code to run. For python, this is a multi-line script. For shell, a single command."},
118
+ "type": {"type": "string", "enum": ["python", "bash", "shell"], "description": "Code type", "default": "python"},
119
+ "timeout": {"type": "integer", "description": "Timeout in seconds", "default": 60},
120
+ "cwd": {"type": "string", "description": "Working directory"},
121
+ }, "required": ["script"]},
122
+ }},
123
+ {"type": "function", "function": {
124
+ "name": "file_read",
125
+ "description": "Read file content. Read before modify for latest context and line numbers.",
126
+ "parameters": {"type": "object", "properties": {
127
+ "path": {"type": "string", "description": "Relative or absolute file path"},
128
+ "start": {"type": "integer", "description": "Start line (1-based)", "default": 1},
129
+ "count": {"type": "integer", "description": "Number of lines to read", "default": 200},
130
+ "show_linenos": {"type": "boolean", "description": "Show line numbers", "default": True},
131
+ }, "required": ["path"]},
132
+ }},
133
+ {"type": "function", "function": {
134
+ "name": "file_write",
135
+ "description": "Create/overwrite/append files. Content goes in the 'content' parameter.",
136
+ "parameters": {"type": "object", "properties": {
137
+ "path": {"type": "string", "description": "File path"},
138
+ "content": {"type": "string", "description": "Content to write"},
139
+ "mode": {"type": "string", "enum": ["overwrite", "append", "prepend"], "description": "Write mode", "default": "overwrite"},
140
+ }, "required": ["path", "content"]},
141
+ }},
142
+ {"type": "function", "function": {
143
+ "name": "file_patch",
144
+ "description": "Replace a unique old_content block with new_content. Exact match required. On failure, file_read to recheck.",
145
+ "parameters": {"type": "object", "properties": {
146
+ "path": {"type": "string", "description": "File path"},
147
+ "old_content": {"type": "string", "description": "Original text block to replace (must be unique in the file)"},
148
+ "new_content": {"type": "string", "description": "New content"},
149
+ }, "required": ["path", "old_content", "new_content"]},
150
+ }},
151
+ {"type": "function", "function": {
152
+ "name": "web_scan",
153
+ "description": "Fetch a URL and return simplified text content. Uses HTTP requests (no JavaScript rendering).",
154
+ "parameters": {"type": "object", "properties": {
155
+ "url": {"type": "string", "description": "URL to fetch"},
156
+ "text_only": {"type": "boolean", "description": "Return plain text only (strip HTML)", "default": True},
157
+ }, "required": ["url"]},
158
+ }},
159
+ {"type": "function", "function": {
160
+ "name": "web_execute_js",
161
+ "description": "Execute JavaScript in a browser. Requires TMWebDriver extension to be set up. Returns setup instructions if not available.",
162
+ "parameters": {"type": "object", "properties": {
163
+ "script": {"type": "string", "description": "JavaScript code to execute"},
164
+ "switch_tab_id": {"type": "string", "description": "Optional tab ID"},
165
+ }, "required": ["script"]},
166
+ }},
167
+ {"type": "function", "function": {
168
+ "name": "ask_user",
169
+ "description": "Interrupt task to ask the user for decisions, extra info, or to resolve blockers.",
170
+ "parameters": {"type": "object", "properties": {
171
+ "question": {"type": "string", "description": "Question for the user"},
172
+ "candidates": {"type": "array", "items": {"type": "string"}, "description": "Optional quick-select choices"},
173
+ }, "required": ["question"]},
174
+ }},
175
+ {"type": "function", "function": {
176
+ "name": "update_working_checkpoint",
177
+ "description": "Short-term working notepad. Auto-injected each turn to prevent info loss in long tasks. Call during early/mid stages.",
178
+ "parameters": {"type": "object", "properties": {
179
+ "key_info": {"type": "string", "description": "Key info to remember (<200 tokens): pitfalls, requirements, findings, file paths, progress, next steps."},
180
+ "related_sop": {"type": "string", "description": "Related SOP names for further reading"},
181
+ }, "required": ["key_info"]},
182
+ }},
183
+ {"type": "function", "function": {
184
+ "name": "start_long_term_update",
185
+ "description": "Start distilling long-term memory. Call when discovering info worth remembering (env facts, user prefs, lessons learned).",
186
+ "parameters": {"type": "object", "properties": {}},
187
+ }},
188
+ ]
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Helpers
193
+ # ---------------------------------------------------------------------------
194
+ def smart_format(data, max_str_len=200, omit_str="\n\n[omitted long content]\n\n"):
195
+ if not isinstance(data, str):
196
+ data = str(data)
197
+ if len(data) < max_str_len + len(omit_str) * 2:
198
+ return data
199
+ return f"{data[:max_str_len // 2]}{omit_str}{data[-max_str_len // 2:]}"
200
+
201
+
202
+ def format_error(e):
203
+ exc_type, exc_value, exc_tb = sys.exc_info()
204
+ tb = traceback.extract_tb(exc_tb)
205
+ if tb:
206
+ f = tb[-1]
207
+ return f"{exc_type.__name__}: {exc_value} @ {os.path.basename(f.filename)}:{f.lineno}"
208
+ return f"{exc_type.__name__}: {exc_value}"
209
+
210
+
211
+ def clean_reply(text):
212
+ """Strip internal tags for display."""
213
+ for pat in [r"<thinking>[\s\S]*?</thinking>", r"<summary>[\s\S]*?</summary>",
214
+ r"<tool_use>[\s\S]*?</tool_use>", r"<file_content>[\s\S]*?</file_content>"]:
215
+ text = re.sub(pat, "", text or "", flags=re.DOTALL)
216
+ return re.sub(r"\n{3,}", "\n\n", text).strip() or "..."
217
+
218
+
219
+ def extract_files(text):
220
+ return re.findall(r"\[FILE:([^\]]+)\]", text or "")
221
+
222
+
223
+ def strip_files(text):
224
+ return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Tool implementations
229
+ # ---------------------------------------------------------------------------
230
+ def tool_code_run(script, code_type="python", timeout=60, cwd=None, stop_signal=None):
231
+ """Execute Python or shell code. Yields progress, returns result dict."""
232
+ cwd = cwd or TEMP_DIR
233
+ os.makedirs(cwd, exist_ok=True)
234
+ preview = (script[:80].replace("\n", " ") + "...") if len(script) > 80 else script.strip()
235
+ yield f"[Action] Running {code_type}: {preview}\n"
236
+
237
+ if code_type in ("python", "py"):
238
+ tmp = tempfile.NamedTemporaryFile(suffix=".ai.py", delete=False, mode="w", encoding="utf-8", dir=cwd)
239
+ tmp.write(script)
240
+ tmp.close()
241
+ cmd = [sys.executable, "-X", "utf8", "-u", tmp.name]
242
+ elif code_type in ("bash", "shell", "sh"):
243
+ cmd = ["bash", "-c", script]
244
+ else:
245
+ return {"status": "error", "msg": f"Unsupported type: {code_type}"}
246
+
247
+ try:
248
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
249
+ bufsize=0, cwd=cwd, text=True, encoding="utf-8", errors="replace")
250
+ stdout_lines = []
251
+ start_t = time.time()
252
+ for line in iter(proc.stdout.readline, ""):
253
+ stdout_lines.append(line)
254
+ if time.time() - start_t > timeout or (stop_signal and stop_signal):
255
+ proc.kill()
256
+ stdout_lines.append("\n[Stopped] Timeout or user abort\n")
257
+ break
258
+ proc.wait(timeout=5)
259
+ exit_code = proc.returncode
260
+ stdout_str = "".join(stdout_lines)
261
+ status = "success" if exit_code == 0 else "error"
262
+ icon = "✅" if exit_code == 0 else "❌"
263
+ yield f"[Status] {icon} Exit Code: {exit_code}\n[Stdout]\n{smart_format(stdout_str, max_str_len=8000)}\n"
264
+ return {"status": status, "stdout": smart_format(stdout_str, max_str_len=10000), "exit_code": exit_code}
265
+ except Exception as e:
266
+ return {"status": "error", "msg": str(e)}
267
+ finally:
268
+ if code_type == "python" and "tmp" in dir() and os.path.exists(tmp.name):
269
+ os.remove(tmp.name)
270
+
271
+
272
+ def tool_file_read(path, start=1, count=200, show_linenos=True):
273
+ """Read file content with optional line numbers."""
274
+ try:
275
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
276
+ lines = f.readlines()
277
+ end = min(start - 1 + count, len(lines))
278
+ result_lines = lines[start - 1:end]
279
+ if show_linenos:
280
+ result = f"[FILE] {len(lines)} lines | showing {start}-{end}\n"
281
+ result += "\n".join(f"{start + i}|{line.rstrip()}" for i, line in enumerate(result_lines))
282
+ else:
283
+ result = "".join(result_lines)
284
+ remaining = len(lines) - end
285
+ if remaining > 0:
286
+ result += f"\n\n[{remaining} more lines below]"
287
+ return smart_format(result, max_str_len=15000)
288
+ except FileNotFoundError:
289
+ return f"Error: File not found: {path}"
290
+ except Exception as e:
291
+ return f"Error: {e}"
292
+
293
+
294
+ def tool_file_write(path, content, mode="overwrite"):
295
+ """Write content to file."""
296
+ try:
297
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
298
+ if mode == "prepend":
299
+ old = open(path, "r", encoding="utf-8").read() if os.path.exists(path) else ""
300
+ with open(path, "w", encoding="utf-8") as f:
301
+ f.write(content + old)
302
+ elif mode == "append":
303
+ with open(path, "a", encoding="utf-8") as f:
304
+ f.write(content)
305
+ else:
306
+ with open(path, "w", encoding="utf-8") as f:
307
+ f.write(content)
308
+ return {"status": "success", "writed_bytes": len(content)}
309
+ except Exception as e:
310
+ return {"status": "error", "msg": str(e)}
311
+
312
+
313
+ def tool_file_patch(path, old_content, new_content):
314
+ """Replace unique old_content with new_content in file."""
315
+ try:
316
+ if not os.path.exists(path):
317
+ return {"status": "error", "msg": "File not found"}
318
+ with open(path, "r", encoding="utf-8") as f:
319
+ full = f.read()
320
+ if not old_content:
321
+ return {"status": "error", "msg": "old_content is empty"}
322
+ count = full.count(old_content)
323
+ if count == 0:
324
+ return {"status": "error", "msg": "old_content not found. Use file_read to check current content."}
325
+ if count > 1:
326
+ return {"status": "error", "msg": f"Found {count} matches. Provide a longer, more specific old_content."}
327
+ updated = full.replace(old_content, new_content)
328
+ with open(path, "w", encoding="utf-8") as f:
329
+ f.write(updated)
330
+ return {"status": "success", "msg": "File patched successfully"}
331
+ except Exception as e:
332
+ return {"status": "error", "msg": str(e)}
333
+
334
+
335
+ def tool_web_scan(url, text_only=True):
336
+ """Fetch a URL and return text content."""
337
+ if requests is None:
338
+ return {"status": "error", "msg": "requests library not installed. Run: pip install requests"}
339
+ try:
340
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
341
+ resp = requests.get(url, headers=headers, timeout=30, verify=False)
342
+ content_type = resp.headers.get("content-type", "")
343
+ if text_only and "html" in content_type:
344
+ # Simple HTML to text
345
+ import html.parser
346
+ class _Stripper(html.parser.HTMLParser):
347
+ def __init__(self):
348
+ super().__init__(); self.text = []
349
+ def handle_data(self, d): self.text.append(d)
350
+ def get_text(self): return " ".join(self.text)
351
+ s = _Stripper()
352
+ s.feed(resp.text)
353
+ text = re.sub(r"\s+", " ", s.get_text()).strip()
354
+ return {"status": "success", "url": str(resp.url), "content": smart_format(text, max_str_len=30000)}
355
+ return {"status": "success", "url": str(resp.url), "content": smart_format(resp.text, max_str_len=30000)}
356
+ except Exception as e:
357
+ return {"status": "error", "msg": format_error(e)}
358
+
359
+
360
+ def tool_web_execute_js(script, switch_tab_id=None):
361
+ """Execute JS in browser. Requires TMWebDriver (not bundled)."""
362
+ return {"status": "error", "msg": "Browser JS execution requires TMWebDriver. To set up: install the Chrome extension from the GenericAgent repo's assets/tmwd_cdp_bridge/ directory, or use web_scan for basic HTTP fetching."}
363
+
364
+
365
+ def tool_ask_user(question, candidates=None):
366
+ """Return an interrupt signal for human intervention."""
367
+ return {"status": "INTERRUPT", "intent": "HUMAN_INTERVENTION",
368
+ "data": {"question": question, "candidates": candidates or []}}
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # Step outcome + handler
373
+ # ---------------------------------------------------------------------------
374
+ @dataclass
375
+ class StepOutcome:
376
+ data: Any
377
+ next_prompt: Optional[str] = None
378
+ should_exit: bool = False
379
+
380
+
381
+ class AgentHandler:
382
+ """Dispatches tool calls and manages working memory + history."""
383
+
384
+ def __init__(self, parent, last_history=None, cwd=TEMP_DIR):
385
+ self.parent = parent
386
+ self.working = {}
387
+ self.cwd = cwd
388
+ self.current_turn = 0
389
+ self.history_info = last_history if last_history else []
390
+ self.code_stop_signal = []
391
+ self.max_turns = 180
392
+
393
+ def _get_abs_path(self, path):
394
+ if not path:
395
+ return ""
396
+ if os.path.isabs(path):
397
+ return path
398
+ return os.path.abspath(os.path.join(self.cwd, path))
399
+
400
+ def _get_anchor_prompt(self):
401
+ """Build the working-memory prompt injected each turn."""
402
+ h = self.history_info
403
+ W = 30
404
+ h_str = "\n".join(h[-W:])
405
+ prompt = f"\n### [WORKING MEMORY]\n<history>\n{h_str}\n</history>"
406
+ prompt += f"\nCurrent turn: {self.current_turn}\n"
407
+ if self.working.get("key_info"):
408
+ prompt += f"\n<key_info>{self.working.get('key_info')}</key_info>"
409
+ return prompt
410
+
411
+ def dispatch(self, tool_name, args, response, index=0, tool_num=1):
412
+ """Dispatch a tool call. Yields progress text, returns StepOutcome."""
413
+ method = getattr(self, f"do_{tool_name}", None)
414
+ if method is None:
415
+ yield f"Unknown tool: {tool_name}\n"
416
+ return StepOutcome(None, next_prompt=f"Unknown tool {tool_name}")
417
+ args["_index"] = index
418
+ args["_tool_num"] = tool_num
419
+ ret = method(args, response)
420
+ # Handle both generators and direct returns
421
+ if hasattr(ret, "__iter__") and not isinstance(ret, (str, dict, list)):
422
+ return (yield from ret)
423
+ return ret
424
+
425
+ def turn_end_callback(self, response, tool_calls, tool_results, turn, next_prompt, exit_reason):
426
+ """Extract summary and add periodic reminders."""
427
+ content = getattr(response, "content", "") or ""
428
+ rsumm = re.search(r"<summary>(.*?)</summary>", content, re.DOTALL)
429
+ if rsumm:
430
+ summary = rsumm.group(1).strip()
431
+ else:
432
+ tc = tool_calls[0]
433
+ tool_name, targs = tc["tool_name"], tc["args"]
434
+ clean_args = {k: v for k, v in targs.items() if not k.startswith("_")}
435
+ summary = f"{tool_name}, args: {clean_args}"
436
+ if tool_name == "no_tool":
437
+ summary = "Responded directly"
438
+ summary = smart_format(summary.replace("\n", ""), max_str_len=80)
439
+ self.history_info.append(f"[Agent] {summary}")
440
+
441
+ if turn % 7 == 0:
442
+ next_prompt += f"\n\n[SYSTEM] Turn {turn}. Call update_working_checkpoint to save key context."
443
+ return next_prompt
444
+
445
+ # ---- tool implementations ----
446
+ def do_code_run(self, args, response):
447
+ code = args.get("script") or args.get("code")
448
+ if not code:
449
+ return StepOutcome("[Error] No code provided.", next_prompt="\n")
450
+ code_type = args.get("type", "python")
451
+ timeout = int(args.get("timeout", 60))
452
+ cwd = self._get_abs_path(args.get("cwd", "./"))
453
+ result = yield from tool_code_run(code, code_type, timeout, cwd, self.code_stop_signal)
454
+ return StepOutcome(result, next_prompt=self._get_anchor_prompt())
455
+
456
+ def do_file_read(self, args, response):
457
+ path = self._get_abs_path(args.get("path", ""))
458
+ yield f"[Action] Reading: {path}\n"
459
+ result = tool_file_read(path, args.get("start", 1), args.get("count", 200), args.get("show_linenos", True))
460
+ return StepOutcome(smart_format(result, max_str_len=15000), next_prompt=self._get_anchor_prompt())
461
+
462
+ def do_file_write(self, args, response):
463
+ path = self._get_abs_path(args.get("path", ""))
464
+ content = args.get("content", "")
465
+ mode = args.get("mode", "overwrite")
466
+ yield f"[Action] Writing {mode}: {os.path.basename(path)}\n"
467
+ result = tool_file_write(path, content, mode)
468
+ yield f"[Status] {result}\n"
469
+ return StepOutcome(result, next_prompt=self._get_anchor_prompt())
470
+
471
+ def do_file_patch(self, args, response):
472
+ path = self._get_abs_path(args.get("path", ""))
473
+ yield f"[Action] Patching: {path}\n"
474
+ result = tool_file_patch(path, args.get("old_content", ""), args.get("new_content", ""))
475
+ yield f"{result}\n"
476
+ return StepOutcome(result, next_prompt=self._get_anchor_prompt())
477
+
478
+ def do_web_scan(self, args, response):
479
+ url = args.get("url", "")
480
+ yield f"[Action] Fetching: {url}\n"
481
+ result = tool_web_scan(url, args.get("text_only", True))
482
+ yield f"[Result] {smart_format(str(result), max_str_len=500)}\n"
483
+ return StepOutcome(smart_format(json.dumps(result, ensure_ascii=False), max_str_len=8000),
484
+ next_prompt=self._get_anchor_prompt())
485
+
486
+ def do_web_execute_js(self, args, response):
487
+ result = tool_web_execute_js(args.get("script", ""), args.get("switch_tab_id"))
488
+ yield f"[Result] {result}\n"
489
+ return StepOutcome(result, next_prompt=self._get_anchor_prompt())
490
+
491
+ def do_ask_user(self, args, response):
492
+ question = args.get("question", "Please provide input:")
493
+ candidates = args.get("candidates", [])
494
+ result = tool_ask_user(question, candidates)
495
+ yield f"Waiting for your answer...\n"
496
+ return StepOutcome(result, next_prompt="", should_exit=True)
497
+
498
+ def do_update_working_checkpoint(self, args, response):
499
+ key_info = args.get("key_info", "")
500
+ related_sop = args.get("related_sop", "")
501
+ if key_info:
502
+ self.working["key_info"] = key_info
503
+ if related_sop:
504
+ self.working["related_sop"] = related_sop
505
+ yield f"[Info] Updated working memory.\n"
506
+ return StepOutcome({"result": "working key_info updated"}, next_prompt=self._get_anchor_prompt())
507
+
508
+ def do_start_long_term_update(self, args, response):
509
+ prompt = """### [Distill Experience]
510
+ Extract verified, long-term-useful info from the recent task:
511
+ - **Environment facts** (paths, configs) → file_patch into memory/global_mem.txt
512
+ - **Task experience** (pitfalls, key steps) → write a new SOP .md in memory/
513
+
514
+ Only extract verified, reusable info. Skip ephemeral data, unverified guesses, or common knowledge.
515
+ Use file_read to check existing memory first, then file_patch for minimal updates.
516
+ """
517
+ yield "[Info] Starting long-term memory distillation.\n"
518
+ insight = ""
519
+ insight_path = os.path.join(MEMORY_DIR, "global_mem_insight.txt")
520
+ if os.path.exists(insight_path):
521
+ with open(insight_path, "r", encoding="utf-8") as f:
522
+ insight = f.read()
523
+ return StepOutcome(f"Current L1 insight:\n{insight}", next_prompt=prompt)
524
+
525
+ def do_no_tool(self, args, response):
526
+ """Called when the LLM doesn't invoke any tool — signals task completion."""
527
+ content = getattr(response, "content", "") or ""
528
+ if not content.strip():
529
+ yield "[Warn] Empty response. Retrying...\n"
530
+ return StepOutcome({}, next_prompt="[System] Blank response, please respond with content or a tool call.")
531
+ return StepOutcome(response, next_prompt=None)
532
+
533
+
534
+ # ---------------------------------------------------------------------------
535
+ # LLM Client (OpenAI-compatible, streaming, with function calling)
536
+ # ---------------------------------------------------------------------------
537
+ class LLMResponse:
538
+ """Parsed LLM response."""
539
+ def __init__(self, content="", tool_calls=None, raw=None):
540
+ self.content = content
541
+ self.tool_calls = tool_calls or []
542
+ self.raw = raw or ""
543
+
544
+
545
+ class MockToolCall:
546
+ def __init__(self, name, args, id=""):
547
+ self.id = id
548
+ self.function = type("F", (), {"name": name, "arguments": json.dumps(args, ensure_ascii=False)})()
549
+
550
+
551
+ class LLMClient:
552
+ """OpenAI-compatible streaming LLM client with function-calling support.
553
+
554
+ Works with OpenAI, DeepSeek, Kimi/Moonshot, local models (Ollama, vLLM),
555
+ and any endpoint that implements /v1/chat/completions with tools.
556
+ """
557
+
558
+ def __init__(self, cfg):
559
+ self.api_key = cfg.get("apikey", "")
560
+ self.api_base = cfg.get("apibase", "https://api.openai.com").rstrip("/")
561
+ self.model = cfg.get("model", "gpt-4o")
562
+ self.name = cfg.get("name", self.model)
563
+ self.context_win = cfg.get("context_win", 30000)
564
+ self.temperature = cfg.get("temperature", 1)
565
+ self.max_tokens = cfg.get("max_tokens")
566
+ self.stream = cfg.get("stream", True)
567
+ self.connect_timeout = cfg.get("timeout", 10)
568
+ self.read_timeout = cfg.get("read_timeout", 240)
569
+ self.max_retries = cfg.get("max_retries", 3)
570
+ self.history = []
571
+ self.system = ""
572
+ self.tools = None
573
+ self._lock = threading.Lock()
574
+ self.log_path = None
575
+
576
+ def _make_url(self):
577
+ base = self.api_base.rstrip("/")
578
+ if "/v1" in base:
579
+ return f"{base}/chat/completions"
580
+ return f"{base}/v1/chat/completions"
581
+
582
+ def _build_messages(self, messages):
583
+ """Convert internal message format to OpenAI format."""
584
+ msgs = []
585
+ if self.system:
586
+ msgs.append({"role": "system", "content": self.system})
587
+ for m in messages:
588
+ role = m.get("role", "user")
589
+ content = m.get("content", "")
590
+ tool_results = m.get("tool_results", [])
591
+ if isinstance(content, list):
592
+ # Extract text from content blocks
593
+ texts = []
594
+ for b in content:
595
+ if isinstance(b, dict) and b.get("type") == "text":
596
+ texts.append(b.get("text", ""))
597
+ elif isinstance(b, str):
598
+ texts.append(b)
599
+ content = "\n".join(texts)
600
+ if tool_results:
601
+ for tr in tool_results:
602
+ msgs.append({"role": "tool", "tool_call_id": tr.get("tool_use_id", ""),
603
+ "content": tr.get("content", "")})
604
+ if content:
605
+ msgs.append({"role": role, "content": str(content)})
606
+ return msgs
607
+
608
+ def chat(self, messages, tools=None):
609
+ """Stream LLM response. Yields text chunks, returns LLMResponse."""
610
+ if requests is None:
611
+ raise RuntimeError("requests library required. Install: pip install requests")
612
+
613
+ self.tools = tools
614
+ oai_messages = self._build_messages(messages)
615
+ url = self._make_url()
616
+ headers = {
617
+ "Authorization": f"Bearer {self.api_key}",
618
+ "Content-Type": "application/json",
619
+ "Accept": "text/event-stream" if self.stream else "application/json",
620
+ }
621
+ payload = {"model": self.model, "messages": oai_messages, "stream": self.stream}
622
+ if self.temperature != 1:
623
+ payload["temperature"] = self.temperature
624
+ if self.max_tokens:
625
+ payload["max_tokens"] = self.max_tokens
626
+ if tools:
627
+ payload["tools"] = tools
628
+ if self.stream:
629
+ payload["stream_options"] = {"include_usage": True}
630
+
631
+ # Retry logic
632
+ retryable = {408, 409, 425, 429, 500, 502, 503, 504}
633
+ for attempt in range(self.max_retries + 1):
634
+ try:
635
+ with requests.post(url, headers=headers, json=payload, stream=self.stream,
636
+ timeout=(self.connect_timeout, self.read_timeout)) as resp:
637
+ if resp.status_code >= 400:
638
+ body = resp.text[:500]
639
+ if resp.status_code in retryable and attempt < self.max_retries:
640
+ delay = min(30, 1.5 * (2 ** attempt))
641
+ print(f"[LLM Retry] HTTP {resp.status_code}, retry in {delay:.1f}s")
642
+ time.sleep(delay)
643
+ continue
644
+ err_text = f"!!!Error: HTTP {resp.status_code}: {body}"
645
+ yield err_text
646
+ return LLMResponse(content=err_text)
647
+
648
+ if self.stream:
649
+ return (yield from self._parse_stream(resp))
650
+ else:
651
+ return self._parse_json(resp.json())
652
+ except (requests.Timeout, requests.ConnectionError) as e:
653
+ err = f"!!!Error: {type(e).__name__}: {e}"
654
+ if attempt < self.max_retries:
655
+ delay = min(30, 1.5 * (2 ** attempt))
656
+ print(f"[LLM Retry] {type(e).__name__}, retry in {delay:.1f}s")
657
+ yield err
658
+ time.sleep(delay)
659
+ continue
660
+ yield err
661
+ return LLMResponse(content=err)
662
+ return LLMResponse(content="!!!Error: Max retries exceeded")
663
+
664
+ def _parse_stream(self, resp):
665
+ """Parse OpenAI SSE stream. Yields text chunks, returns LLMResponse."""
666
+ content_text = ""
667
+ tc_buf = {} # index -> {id, name, args}
668
+
669
+ for line in resp.iter_lines():
670
+ if not line:
671
+ continue
672
+ line = line.decode("utf-8", errors="replace") if isinstance(line, bytes) else line
673
+ if not line.startswith("data:"):
674
+ continue
675
+ data_str = line[5:].strip()
676
+ if data_str == "[DONE]":
677
+ break
678
+ try:
679
+ evt = json.loads(data_str)
680
+ except json.JSONDecodeError:
681
+ continue
682
+
683
+ choices = evt.get("choices") or [{}]
684
+ ch = choices[0]
685
+ delta = ch.get("delta") or {}
686
+
687
+ # Reasoning content (some providers)
688
+ if rc := delta.get("reasoning_content") or delta.get("reasoning", ""):
689
+ pass # silently consume reasoning
690
+
691
+ if delta.get("content"):
692
+ text = delta["content"]
693
+ content_text += text
694
+ yield text
695
+
696
+ for tc in (delta.get("tool_calls") or []):
697
+ idx = tc.get("index", 0)
698
+ if idx not in tc_buf:
699
+ tc_buf[idx] = {"id": "", "name": "", "args": ""}
700
+ func = tc.get("function", {})
701
+ if func.get("name"):
702
+ tc_buf[idx]["name"] = func["name"]
703
+ if func.get("arguments"):
704
+ tc_buf[idx]["args"] += func["arguments"]
705
+ if tc.get("id") and not tc_buf[idx]["id"]:
706
+ tc_buf[idx]["id"] = tc["id"]
707
+
708
+ # Build tool calls
709
+ tool_calls = []
710
+ for idx in sorted(tc_buf):
711
+ tc = tc_buf[idx]
712
+ try:
713
+ args = json.loads(tc["args"]) if tc["args"] else {}
714
+ except json.JSONDecodeError:
715
+ args = {"_raw": tc["args"]}
716
+ tool_calls.append(MockToolCall(tc["name"], args, id=tc["id"]))
717
+
718
+ return LLMResponse(content=content_text, tool_calls=tool_calls)
719
+
720
+ def _parse_json(self, data):
721
+ """Parse non-streaming JSON response."""
722
+ msg = (data.get("choices") or [{}])[0].get("message", {})
723
+ content = msg.get("content", "") or ""
724
+ tool_calls = []
725
+ for tc in (msg.get("tool_calls") or []):
726
+ fn = tc.get("function", {})
727
+ try:
728
+ args = json.loads(fn.get("arguments", "")) if fn.get("arguments") else {}
729
+ except json.JSONDecodeError:
730
+ args = {"_raw": fn.get("arguments", "")}
731
+ tool_calls.append(MockToolCall(fn.get("name", ""), args, id=tc.get("id", "")))
732
+ return LLMResponse(content=content, tool_calls=tool_calls)
733
+
734
+
735
+ # ---------------------------------------------------------------------------
736
+ # Agent Loop
737
+ # ---------------------------------------------------------------------------
738
+ def agent_loop(client, system_prompt, user_input, handler, tools_schema,
739
+ max_turns=180, verbose=True):
740
+ """The core agent loop: LLM → tool dispatch → next prompt → repeat.
741
+
742
+ Yields text chunks for streaming. Returns when the task is done.
743
+ """
744
+ messages = [
745
+ {"role": "system", "content": system_prompt},
746
+ {"role": "user", "content": [{"type": "text", "text": user_input}]},
747
+ ]
748
+ handler.max_turns = max_turns
749
+ turn = 0
750
+
751
+ while turn < handler.max_turns:
752
+ turn += 1
753
+ yield f"\n\n**LLM Running (Turn {turn}) ...**\n\n"
754
+
755
+ # Call LLM
756
+ client.system = system_prompt
757
+ try:
758
+ response = yield from client.chat(messages, tools=tools_schema)
759
+ except Exception as e:
760
+ yield f"\n[Error] LLM call failed: {format_error(e)}\n"
761
+ break
762
+
763
+ if response is None:
764
+ response = LLMResponse(content="!!!Error: No response")
765
+
766
+ # Parse tool calls
767
+ if response.tool_calls:
768
+ tool_calls = [{"tool_name": tc.function.name,
769
+ "args": json.loads(tc.function.arguments) if tc.function.arguments else {},
770
+ "id": tc.id} for tc in response.tool_calls]
771
+ else:
772
+ tool_calls = [{"tool_name": "no_tool", "args": {}}]
773
+
774
+ tool_results = []
775
+ next_prompts = set()
776
+ exit_reason = {}
777
+
778
+ for ii, tc in enumerate(tool_calls):
779
+ tool_name, args, tid = tc["tool_name"], tc["args"], tc.get("id", "")
780
+ if tool_name != "no_tool":
781
+ yield f"🛠️ Tool: `{tool_name}` 📥 args:\n````text\n{json.dumps(args, indent=2, ensure_ascii=False)}\n````\n"
782
+
783
+ handler.current_turn = turn
784
+ gen = handler.dispatch(tool_name, args, response, index=ii, tool_num=len(tool_calls))
785
+ try:
786
+ v = next(gen)
787
+ if verbose:
788
+ yield "`````\n"
789
+ outcome = (yield from gen) if verbose else (yield from gen)
790
+ if verbose:
791
+ yield "`````\n"
792
+ except StopIteration as e:
793
+ outcome = e.value
794
+
795
+ if outcome is None:
796
+ outcome = StepOutcome(None, next_prompt="\n")
797
+
798
+ if outcome.should_exit:
799
+ exit_reason = {"result": "EXITED", "data": outcome.data}
800
+ break
801
+ if not outcome.next_prompt:
802
+ exit_reason = {"result": "CURRENT_TASK_DONE", "data": outcome.data}
803
+ break
804
+
805
+ if outcome.data is not None and tool_name != "no_tool":
806
+ datastr = json.dumps(outcome.data, ensure_ascii=False, default=str) if isinstance(outcome.data, (dict, list)) else str(outcome.data)
807
+ tool_results.append({"tool_use_id": tid, "content": datastr})
808
+ next_prompts.add(outcome.next_prompt)
809
+
810
+ if not next_prompts or exit_reason:
811
+ break
812
+
813
+ next_prompt = handler.turn_end_callback(response, tool_calls, tool_results, turn,
814
+ "\n".join(next_prompts), exit_reason)
815
+ messages = [{"role": "user", "content": next_prompt, "tool_results": tool_results}]
816
+
817
+ return exit_reason or {"result": "MAX_TURNS_EXCEEDED"}
818
+
819
+
820
+ # ---------------------------------------------------------------------------
821
+ # Config loading (mykey.py / mykey.json)
822
+ # ---------------------------------------------------------------------------
823
+ def load_mykeys():
824
+ """Load LLM configs from mykey.py or mykey.json.
825
+
826
+ Format (mykey.py):
827
+ llm1 = {"apikey": "sk-...", "apibase": "https://api.openai.com", "model": "gpt-4o", "name": "GPT-4o"}
828
+ llm2 = {"apikey": "sk-...", "apibase": "https://api.deepseek.com", "model": "deepseek-chat", "name": "DeepSeek"}
829
+
830
+ Format (mykey.json):
831
+ {"llm1": {"apikey": "...", "apibase": "...", "model": "...", "name": "..."}}
832
+ """
833
+ # Try mykey.py
834
+ for search_dir in [SCRIPT_DIR, os.getcwd()]:
835
+ mykey_py = os.path.join(search_dir, "mykey.py")
836
+ if os.path.exists(mykey_py):
837
+ import importlib.util
838
+ spec = importlib.util.spec_from_file_location("mykey", mykey_py)
839
+ mod = importlib.util.module_from_spec(spec)
840
+ spec.loader.exec_module(mod)
841
+ return {k: v for k, v in vars(mod).items()
842
+ if not k.startswith("_") and isinstance(v, dict) and "apikey" in v}
843
+
844
+ # Try mykey.json
845
+ for search_dir in [SCRIPT_DIR, os.getcwd()]:
846
+ mykey_json = os.path.join(search_dir, "mykey.json")
847
+ if os.path.exists(mykey_json):
848
+ with open(mykey_json, "r", encoding="utf-8") as f:
849
+ data = json.load(f)
850
+ return {k: v for k, v in data.items()
851
+ if isinstance(v, dict) and "apikey" in v}
852
+
853
+ return {}
854
+
855
+
856
+ def get_global_memory():
857
+ """Read L1 + L2 memory for the system prompt."""
858
+ prompt = f"\ncwd = {TEMP_DIR} (./)\n"
859
+ prompt += f"\n[Memory] ({MEMORY_DIR})\n"
860
+ insight_path = os.path.join(MEMORY_DIR, "global_mem_insight.txt")
861
+ if os.path.exists(insight_path):
862
+ with open(insight_path, "r", encoding="utf-8", errors="replace") as f:
863
+ prompt += f"L1 Insight:\n{f.read()}\n"
864
+ return prompt
865
+
866
+
867
+ # ---------------------------------------------------------------------------
868
+ # Agent (the main class users interact with)
869
+ # ---------------------------------------------------------------------------
870
+ class Agent:
871
+ """The main agent. Put tasks on a queue, drain the output queue for results.
872
+
873
+ Usage:
874
+ agent = Agent()
875
+ threading.Thread(target=agent.run, daemon=True).start()
876
+ dq = agent.put_task("Hello!")
877
+ while True:
878
+ item = dq.get()
879
+ if "done" in item: print(item["done"]); break
880
+ if "next" in item: print(item["next"], end="")
881
+ """
882
+
883
+ def __init__(self):
884
+ self.lock = threading.Lock()
885
+ self.history = []
886
+ self.handler = None
887
+ self.task_queue = queue.Queue()
888
+ self.is_running = False
889
+ self.stop_sig = False
890
+ self.llm_no = 0
891
+ self.inc_out = False
892
+ self.verbose = True
893
+ self.log_path = ""
894
+ self.llmclient = None
895
+ self._llm_clients = []
896
+ self._llm_names = []
897
+ self._load_llms()
898
+
899
+ def _load_llms(self):
900
+ """Initialize LLM clients from mykey config."""
901
+ mykeys = load_mykeys()
902
+ if not mykeys:
903
+ print("[agent_core] WARNING: no LLM config found. Create mykey.py or mykey.json.")
904
+ return
905
+ self._llm_clients = []
906
+ self._llm_names = []
907
+ for k, cfg in mykeys.items():
908
+ try:
909
+ client = LLMClient(cfg)
910
+ self._llm_clients.append(client)
911
+ self._llm_names.append(cfg.get("name", cfg.get("model", k)))
912
+ except Exception as e:
913
+ print(f"[agent_core] Failed to init LLM '{k}': {e}")
914
+ if self._llm_clients:
915
+ self.llmclient = self._llm_clients[0]
916
+
917
+ def list_llms(self):
918
+ """Return [(index, name, is_active), ...]"""
919
+ return [(i, name, i == self.llm_no) for i, name in enumerate(self._llm_names)]
920
+
921
+ def is_configured(self):
922
+ """True only when at least one LLM client has a real (non-placeholder) API key."""
923
+ for c in self._llm_clients:
924
+ key = getattr(c, "api_key", "") or ""
925
+ if key and "YOUR-" not in key and not key.endswith("..."):
926
+ return True
927
+ return False
928
+
929
+ def get_llm_name(self, client=None):
930
+ if client is None:
931
+ client = self.llmclient
932
+ if client is None:
933
+ return "not configured"
934
+ return client.name
935
+
936
+ def next_llm(self, n=-1):
937
+ """Switch LLM. n=-1 for next, or pass an explicit index."""
938
+ if not self._llm_clients:
939
+ raise Exception("No LLM clients available")
940
+ self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self._llm_clients)
941
+ self.llmclient = self._llm_clients[self.llm_no]
942
+ return self.llm_no
943
+
944
+ def abort(self):
945
+ if not self.is_running:
946
+ return
947
+ print("Aborting current task...")
948
+ self.stop_sig = True
949
+ if self.handler is not None:
950
+ self.handler.code_stop_signal.append(1)
951
+
952
+ def put_task(self, query, source="user", images=None):
953
+ """Submit a task. Returns an output queue to drain."""
954
+ display_queue = queue.Queue()
955
+ self.task_queue.put({"query": query, "source": source, "images": images or [], "output": display_queue})
956
+ return display_queue
957
+
958
+ def run(self):
959
+ """Main agent loop (run in a daemon thread)."""
960
+ while True:
961
+ task = self.task_queue.get()
962
+ if isinstance(task, str):
963
+ break
964
+ raw_query, source, display_queue = task["query"], task["source"], task["output"]
965
+ self.is_running = True
966
+ self.stop_sig = False
967
+
968
+ # Truncate very long prompts to a file
969
+ if len(raw_query) > 2000:
970
+ task_file = os.path.join(TEMP_DIR, f"user_prompt_{int(time.time())}.md")
971
+ with open(task_file, "w", encoding="utf-8") as f:
972
+ f.write(raw_query)
973
+ raw_query = f"Long user prompt saved to {task_file}. Read and execute it."
974
+
975
+ rquery = raw_query.replace("\n", " ")[:200]
976
+ self.history.append(f"[USER]: {rquery}")
977
+
978
+ # Build system prompt
979
+ sys_prompt = SYSTEM_PROMPT + f"\nToday: {time.strftime('%Y-%m-%d %a')}\n" + get_global_memory()
980
+
981
+ # Create handler
982
+ handler = AgentHandler(self, self.history, TEMP_DIR)
983
+ if self.handler and "key_info" in self.handler.working:
984
+ handler.working["key_info"] = self.handler.working.get("key_info", "")
985
+ self.handler = handler
986
+
987
+ # Set log path
988
+ import random
989
+ logid = f"{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}"
990
+ self.log_path = os.path.join(TEMP_DIR, f"model_responses/model_responses_{logid}.txt")
991
+ os.makedirs(os.path.dirname(self.log_path), exist_ok=True)
992
+ if self.llmclient:
993
+ self.llmclient.log_path = self.log_path
994
+
995
+ # Run the agent loop
996
+ try:
997
+ full_resp = ""
998
+ last_pos = 0
999
+ curr_turn = 0
1000
+ gen = agent_loop(self.llmclient, sys_prompt, raw_query, handler,
1001
+ TOOLS_SCHEMA, max_turns=180, verbose=self.verbose)
1002
+ for chunk in gen:
1003
+ if self.stop_sig:
1004
+ break
1005
+ if isinstance(chunk, dict) and "turn" in chunk:
1006
+ curr_turn = chunk["turn"]
1007
+ continue
1008
+ full_resp += chunk
1009
+ if len(full_resp) - last_pos > 30 or "LLM Running" in chunk:
1010
+ display_queue.put({
1011
+ "next": full_resp[last_pos:] if self.inc_out else full_resp,
1012
+ "source": source, "turn": curr_turn,
1013
+ })
1014
+ last_pos = len(full_resp)
1015
+ if self.inc_out and last_pos < len(full_resp):
1016
+ display_queue.put({"next": full_resp[last_pos:], "source": source, "turn": curr_turn})
1017
+ display_queue.put({"done": full_resp, "source": source, "turn": curr_turn})
1018
+ self.history = handler.history_info
1019
+ except Exception as e:
1020
+ err = format_error(e)
1021
+ print(f"[agent_core] Error: {err}")
1022
+ display_queue.put({"done": full_resp + f"\n```\n{err}\n```", "source": source})
1023
+ finally:
1024
+ if self.stop_sig:
1025
+ print("[agent_core] Task aborted by user.")
1026
+ self.is_running = self.stop_sig = False
1027
+ self.task_queue.task_done()
1028
+ if self.handler is not None:
1029
+ self.handler.code_stop_signal.append(1)