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.
- package/LICENSE +53 -0
- package/README.md +174 -0
- package/bin/cli.mjs +314 -0
- package/package.json +44 -0
- package/python/agent_core.py +1029 -0
- package/python/webui_codex.html +673 -0
- package/python/webui_codex.py +583 -0
- package/skills/faceless-explainer.md +194 -0
- package/skills/general-video.md +141 -0
- package/skills/hyperframes-animation.md +82 -0
- package/skills/hyperframes-cli.md +109 -0
- package/skills/hyperframes-core.md +78 -0
- package/skills/hyperframes-creative.md +68 -0
- package/skills/hyperframes-media.md +81 -0
- package/skills/hyperframes-registry.md +101 -0
- package/skills/hyperframes.md +144 -0
- package/skills/motion-graphics.md +170 -0
- package/skills/product-launch-video.md +199 -0
- package/skills/website-to-video.md +141 -0
- package/templates/mykey_template.json +14 -0
|
@@ -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)
|