multacd 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- briefing/__init__.py +6 -0
- briefing/generator.py +125 -0
- briefing/privacy.py +112 -0
- briefing/sources/__init__.py +3 -0
- briefing/sources/git_source.py +77 -0
- briefing/sources/news_source.py +76 -0
- briefing/sources/todo_source.py +69 -0
- core/__init__.py +0 -0
- core/agent_loop.py +410 -0
- core/codebase.py +318 -0
- core/config.py +340 -0
- core/llm_client.py +347 -0
- core/mode_manager.py +342 -0
- core/permissions.py +284 -0
- core/plugin_loader.py +194 -0
- core/prompt_composer.py +126 -0
- core/research/__init__.py +0 -0
- core/research/bus.py +41 -0
- core/research/orchestrator.py +638 -0
- core/research/query_generator.py +199 -0
- core/updater.py +187 -0
- core/version.py +20 -0
- daemon/__init__.py +3 -0
- daemon/ipc.py +127 -0
- daemon/process.py +205 -0
- main.py +195 -0
- memory/__init__.py +0 -0
- memory/context.py +117 -0
- memory/store.py +78 -0
- multacd-1.0.0.dist-info/METADATA +94 -0
- multacd-1.0.0.dist-info/RECORD +135 -0
- multacd-1.0.0.dist-info/WHEEL +4 -0
- multacd-1.0.0.dist-info/entry_points.txt +2 -0
- multacd-1.0.0.dist-info/licenses/LICENSE +21 -0
- scheduler/__init__.py +3 -0
- scheduler/engine.py +227 -0
- scheduler/jobs/__init__.py +37 -0
- scheduler/jobs/briefing_job.py +36 -0
- scheduler/jobs/research_job.py +43 -0
- search_providers/__init__.py +41 -0
- search_providers/__main__.py +43 -0
- search_providers/base.py +137 -0
- search_providers/brave.py +87 -0
- search_providers/duckduckgo.py +114 -0
- search_providers/exa.py +99 -0
- search_providers/serpapi.py +85 -0
- search_providers/tavily.py +79 -0
- soul.md +30 -0
- tg/__init__.py +7 -0
- tg/access_control.py +131 -0
- tg/agent.py +268 -0
- tg/bot.py +79 -0
- tg/file_handler.py +152 -0
- tg/formatter.py +94 -0
- tg/handlers.py +358 -0
- tools/__init__.py +0 -0
- tools/agent/__init__.py +0 -0
- tools/agent/ask.py +35 -0
- tools/agent/skill.py +67 -0
- tools/agent/task.py +33 -0
- tools/agent/todo_write.py +54 -0
- tools/code/__init__.py +0 -0
- tools/code/lint_python.py +144 -0
- tools/code/run_python.py +169 -0
- tools/code/run_tests.py +156 -0
- tools/codebase/__init__.py +0 -0
- tools/codebase/scan_codebase.py +28 -0
- tools/common.py +18 -0
- tools/filesystem/__init__.py +0 -0
- tools/filesystem/apply_patch.py +89 -0
- tools/filesystem/delete_file.py +36 -0
- tools/filesystem/edit_file.py +42 -0
- tools/filesystem/glob.py +35 -0
- tools/filesystem/grep.py +78 -0
- tools/filesystem/list_dir.py +46 -0
- tools/filesystem/move_file.py +38 -0
- tools/filesystem/multi_edit.py +46 -0
- tools/filesystem/read_file.py +42 -0
- tools/filesystem/read_many_files.py +35 -0
- tools/filesystem/write_file.py +37 -0
- tools/git/__init__.py +0 -0
- tools/git/_helper.py +44 -0
- tools/git/git_add.py +21 -0
- tools/git/git_branch.py +13 -0
- tools/git/git_checkout.py +25 -0
- tools/git/git_commit.py +21 -0
- tools/git/git_diff.py +18 -0
- tools/git/git_log.py +17 -0
- tools/git/git_merge.py +140 -0
- tools/git/git_pull.py +17 -0
- tools/git/git_push.py +92 -0
- tools/git/git_status.py +13 -0
- tools/memory/__init__.py +0 -0
- tools/memory/forget.py +33 -0
- tools/memory/recall.py +35 -0
- tools/memory/remember.py +34 -0
- tools/personal/__init__.py +3 -0
- tools/personal/cancel_job.py +55 -0
- tools/personal/daemon_status.py +64 -0
- tools/personal/generate_briefing.py +93 -0
- tools/personal/get_jobs.py +52 -0
- tools/personal/schedule_job.py +77 -0
- tools/personal/send_telegram.py +171 -0
- tools/personal/user_manager.py +103 -0
- tools/registry.py +286 -0
- tools/research/__init__.py +0 -0
- tools/research/deep_research.py +148 -0
- tools/research/export_research.py +146 -0
- tools/research/quick_research.py +159 -0
- tools/research/web_scrape.py +172 -0
- tools/research/web_search.py +176 -0
- tools/shell/__init__.py +0 -0
- tools/shell/bash.py +60 -0
- tools/web/__init__.py +0 -0
- tools/web/web_fetch.py +66 -0
- tui/__init__.py +0 -0
- tui/app.py +113 -0
- tui/icons.py +114 -0
- tui/screens/__init__.py +0 -0
- tui/screens/main_screen.py +424 -0
- tui/screens/setup_wizard.py +483 -0
- tui/themes/__init__.py +133 -0
- tui/themes/__main__.py +17 -0
- tui/widgets/__init__.py +0 -0
- tui/widgets/chat_panel.py +83 -0
- tui/widgets/confirm_dialog.py +52 -0
- tui/widgets/diff_viewer.py +68 -0
- tui/widgets/file_tree.py +121 -0
- tui/widgets/input_bar.py +92 -0
- tui/widgets/permission_popup.py +172 -0
- tui/widgets/slash_palette.py +131 -0
- tui/widgets/sources_panel.py +201 -0
- tui/widgets/status_bar.py +70 -0
- tui/widgets/thinking_bar.py +106 -0
- tui/widgets/tool_activity.py +163 -0
core/agent_loop.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Agent loop — ReAct: Reason → Act → Observe.
|
|
2
|
+
|
|
3
|
+
Menyambungkan LLM + tools + memory + permission jadi satu loop.
|
|
4
|
+
Async generator — setiap update di-yield real-time ke TUI:
|
|
5
|
+
|
|
6
|
+
async for event in run_agent(user_input, context, config):
|
|
7
|
+
isinstance(event, AgentText) → tampilkan potongan teks
|
|
8
|
+
isinstance(event, AgentToolStart) → tampilkan 🔧 ... ⏳
|
|
9
|
+
isinstance(event, AgentToolDone) → update jadi ✅ / ❌
|
|
10
|
+
isinstance(event, AgentDone) → agent selesai
|
|
11
|
+
isinstance(event, AgentError) → tampilkan error, loop berhenti
|
|
12
|
+
|
|
13
|
+
Konfirmasi & pertanyaan user di-inject sebagai callback async
|
|
14
|
+
(TUI memasang dialog; default fallback = stdin). Pola ini dipilih
|
|
15
|
+
agar agent_loop tidak tahu-menahu soal UI.
|
|
16
|
+
|
|
17
|
+
Test cepat (tanpa API key, pakai LLM fake):
|
|
18
|
+
python -m core.agent_loop
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from core.config import Config
|
|
29
|
+
from core.llm_client import LLMClient, LLMError, StreamDone, StreamText, setup_client
|
|
30
|
+
from core.permissions import PermissionChecker
|
|
31
|
+
from memory.context import ConversationContext
|
|
32
|
+
from tools.common import fail
|
|
33
|
+
from tools.registry import execute_tool, get_tool_definitions
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from core.mode_manager import ModeManager
|
|
37
|
+
from core.prompt_composer import PromptComposer
|
|
38
|
+
|
|
39
|
+
SYSTEM_PROMPT = (
|
|
40
|
+
"Kamu multacd, coding agent di terminal. Jawab dengan bahasa yang dipakai user "
|
|
41
|
+
"(default: Indonesia santai). "
|
|
42
|
+
"Gunakan tool yang tersedia untuk mengerjakan tugas — baca file dulu sebelum mengedit. "
|
|
43
|
+
"Kalau hasil tool berisi 'Dibatalkan user', hormati itu: cari cara lain atau tanya user. "
|
|
44
|
+
"Jangan panggil tool yang tidak ada di daftar. Jangan cetak JSON mentah ke user."
|
|
45
|
+
)
|
|
46
|
+
# Fallback kalau run_agent dipanggil tanpa composer (stdin/test).
|
|
47
|
+
# Jalur resmi TUI selalu oper composer (lihat tui/app.py + main_screen.py).
|
|
48
|
+
|
|
49
|
+
# confirm: "yes" | "no" | "all" (all = izinkan semua sesi ini)
|
|
50
|
+
ConfirmCallback = Callable[[str, dict[str, Any]], Awaitable[str]]
|
|
51
|
+
AskCallback = Callable[[str], Awaitable[str]]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AgentText:
|
|
56
|
+
delta: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class AgentToolStart:
|
|
61
|
+
call_id: str
|
|
62
|
+
name: str
|
|
63
|
+
params: dict[str, Any]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class AgentToolDone:
|
|
68
|
+
call_id: str
|
|
69
|
+
name: str
|
|
70
|
+
success: bool
|
|
71
|
+
result: dict[str, Any] | None = None # payload penuh (D4: expandable view)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class AgentDone:
|
|
76
|
+
text: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class AgentError:
|
|
81
|
+
message: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
AgentEvent = AgentText | AgentToolStart | AgentToolDone | AgentDone | AgentError
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _stdin_confirm(tool_name: str, params: dict[str, Any]) -> str:
|
|
88
|
+
target = params.get("path") or params.get("command") or params.get("url") or ""
|
|
89
|
+
print(f"⚠️ {tool_name} {target} — izinkan? [y/n/a] ", end="", flush=True)
|
|
90
|
+
try:
|
|
91
|
+
ans = await asyncio.to_thread(input)
|
|
92
|
+
except (EOFError, KeyboardInterrupt):
|
|
93
|
+
return "no"
|
|
94
|
+
ans = ans.strip().lower()
|
|
95
|
+
if ans in ("a", "all"):
|
|
96
|
+
return "all"
|
|
97
|
+
return "yes" if ans in ("y", "yes") else "no"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _stdin_ask(question: str) -> str:
|
|
101
|
+
print(f"❓ {question}")
|
|
102
|
+
try:
|
|
103
|
+
return await asyncio.to_thread(input, "Jawaban: ")
|
|
104
|
+
except (EOFError, KeyboardInterrupt):
|
|
105
|
+
return "(dibatalkan)"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def run_agent(
|
|
109
|
+
user_input: str,
|
|
110
|
+
context: ConversationContext,
|
|
111
|
+
config: Config,
|
|
112
|
+
*,
|
|
113
|
+
llm_client: LLMClient | None = None,
|
|
114
|
+
confirm: ConfirmCallback | None = None,
|
|
115
|
+
ask_user: AskCallback | None = None,
|
|
116
|
+
mode_manager: ModeManager | None = None,
|
|
117
|
+
active_tools: list[str] | None = None,
|
|
118
|
+
composer: PromptComposer | None = None,
|
|
119
|
+
) -> AsyncIterator[AgentEvent]:
|
|
120
|
+
"""Jalankan satu turn agent. Yield AgentEvent secara real-time."""
|
|
121
|
+
def _system_prompt() -> str:
|
|
122
|
+
return composer.compose() if composer is not None else SYSTEM_PROMPT
|
|
123
|
+
|
|
124
|
+
# ── Slash command: intercept sebelum LLM, tidak masuk history ──
|
|
125
|
+
if mode_manager is not None and mode_manager.is_command(user_input):
|
|
126
|
+
result = mode_manager.handle_command(user_input)
|
|
127
|
+
if result.action == "clear":
|
|
128
|
+
context.clear()
|
|
129
|
+
context.add_message("system", _system_prompt())
|
|
130
|
+
yield AgentText(result.message)
|
|
131
|
+
yield AgentDone(result.message)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
llm = llm_client or setup_client(config)
|
|
135
|
+
checker = PermissionChecker(config)
|
|
136
|
+
confirm_cb = confirm or _stdin_confirm
|
|
137
|
+
ask_cb = ask_user or _stdin_ask
|
|
138
|
+
defs = get_tool_definitions()
|
|
139
|
+
# Bukan git repo → tool git disembunyikan dari LLM (lihat ModeManager).
|
|
140
|
+
# Tool plugin selalu ikut (user-extension tersedia di semua mode).
|
|
141
|
+
if active_tools is None:
|
|
142
|
+
tools = defs
|
|
143
|
+
else:
|
|
144
|
+
from tools.registry import plugin_tool_names
|
|
145
|
+
allowed = set(active_tools) | set(plugin_tool_names())
|
|
146
|
+
tools = [d for d in defs if d["function"]["name"] in allowed]
|
|
147
|
+
|
|
148
|
+
if not context.get_messages():
|
|
149
|
+
context.add_message("system", _system_prompt())
|
|
150
|
+
context.add_message("user", user_input)
|
|
151
|
+
|
|
152
|
+
iteration = 0
|
|
153
|
+
while True:
|
|
154
|
+
iteration += 1
|
|
155
|
+
if iteration > config.max_tool_iterations:
|
|
156
|
+
yield AgentError(
|
|
157
|
+
f"⚠️ Mencapai batas {config.max_tool_iterations} iterasi tool. "
|
|
158
|
+
"Berhenti agar tidak infinite loop — coba pecah tugas jadi langkah kecil."
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# ── Reason: tanya LLM (streaming, teks langsung di-yield) ──
|
|
163
|
+
try:
|
|
164
|
+
done: StreamDone | None = None
|
|
165
|
+
async for event in llm.stream_completion(context.get_messages(), tools):
|
|
166
|
+
if isinstance(event, StreamText):
|
|
167
|
+
if event.content:
|
|
168
|
+
yield AgentText(event.content)
|
|
169
|
+
elif isinstance(event, StreamDone):
|
|
170
|
+
done = event
|
|
171
|
+
if done is None:
|
|
172
|
+
# Provider aneh: stream tutup tanpa StreamDone — jangan
|
|
173
|
+
# biarkan assert mentah, kasih pesan yang bisa dibaca user.
|
|
174
|
+
yield AgentError(
|
|
175
|
+
"⚠️ Provider menutup stream tanpa hasil lengkap. "
|
|
176
|
+
"Coba ulangi; kalau berulang, cek status provider / model.")
|
|
177
|
+
return
|
|
178
|
+
except LLMError as e:
|
|
179
|
+
yield AgentError(str(e))
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
context.add_assistant_tool_calls(done.text, done.tool_calls)
|
|
183
|
+
|
|
184
|
+
if not done.tool_calls:
|
|
185
|
+
yield AgentDone(done.text)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# ── Act + Observe: proses tiap tool call ──
|
|
189
|
+
for call in done.tool_calls:
|
|
190
|
+
yield AgentToolStart(call.id, call.name, call.arguments)
|
|
191
|
+
|
|
192
|
+
# Tool "ask" dicegat: jawab via callback, bukan execute_tool (stdin).
|
|
193
|
+
if call.name == "ask":
|
|
194
|
+
question = call.arguments.get("question", "...")
|
|
195
|
+
answer = await ask_cb(str(question))
|
|
196
|
+
ask_result = {"success": True, "result": answer, "error": None}
|
|
197
|
+
context.add_tool_result(call.id, "ask", ask_result)
|
|
198
|
+
yield AgentToolDone(call.id, "ask", True, ask_result)
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
decision = checker.check(call.name, call.arguments)
|
|
202
|
+
if decision == "deny":
|
|
203
|
+
deny_result = fail(
|
|
204
|
+
f"Tool `{call.name}` tidak tersedia. Pakai tool dari daftar yang ada.")
|
|
205
|
+
context.add_tool_result(call.id, call.name, deny_result)
|
|
206
|
+
yield AgentToolDone(call.id, call.name, False, deny_result)
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
if decision == "ask":
|
|
210
|
+
choice = await confirm_cb(call.name, call.arguments)
|
|
211
|
+
if choice == "all":
|
|
212
|
+
checker.approve_all_for_session(call.name)
|
|
213
|
+
choice = "yes"
|
|
214
|
+
if choice != "yes":
|
|
215
|
+
cancel_result = fail(
|
|
216
|
+
f"Dibatalkan user — jangan coba {call.name} yang sama lagi, "
|
|
217
|
+
"cari cara lain atau tanya user.")
|
|
218
|
+
context.add_tool_result(call.id, call.name, cancel_result)
|
|
219
|
+
yield AgentToolDone(call.id, call.name, False, cancel_result)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# to_thread: tool sync jalan di thread, event loop tetap hidup.
|
|
223
|
+
# Tanpa ini, tool research (yang emit event via call_from_thread)
|
|
224
|
+
# deadlock — loop diblok nunggu thread, thread nunggu loop.
|
|
225
|
+
result = await asyncio.to_thread(execute_tool, call.name, call.arguments)
|
|
226
|
+
context.add_tool_result(call.id, call.name, result)
|
|
227
|
+
yield AgentToolDone(call.id, call.name, result["success"], result)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
import asyncio as _asyncio
|
|
232
|
+
|
|
233
|
+
from core.llm_client import ToolCallRequest as _TCR
|
|
234
|
+
|
|
235
|
+
class FakeLLM:
|
|
236
|
+
"""LLM scripted: antrean StreamDone, teksnya di-stream per kata."""
|
|
237
|
+
|
|
238
|
+
def __init__(self, script: list[StreamDone]) -> None:
|
|
239
|
+
self.script = list(script)
|
|
240
|
+
self.seen_messages: list[list[dict]] = []
|
|
241
|
+
|
|
242
|
+
async def stream_completion(self, messages, tools=None):
|
|
243
|
+
self.seen_messages.append(messages)
|
|
244
|
+
done = self.script.pop(0)
|
|
245
|
+
for word in done.text.split():
|
|
246
|
+
yield StreamText(word + " ")
|
|
247
|
+
yield done
|
|
248
|
+
|
|
249
|
+
async def _drain(gen):
|
|
250
|
+
return [e async for e in gen]
|
|
251
|
+
|
|
252
|
+
async def main() -> None:
|
|
253
|
+
cfg = Config(model="m", api_key="k")
|
|
254
|
+
|
|
255
|
+
# 1. Teks murni tanpa tool
|
|
256
|
+
fake = FakeLLM([StreamDone("halo dunia", [])])
|
|
257
|
+
ctx = ConversationContext()
|
|
258
|
+
events = await _drain(run_agent("hi", ctx, cfg, llm_client=fake))
|
|
259
|
+
assert isinstance(events[-1], AgentDone) and "halo" in events[-1].text, events
|
|
260
|
+
assert sum(isinstance(e, AgentText) for e in events) == 2
|
|
261
|
+
roles = [m["role"] for m in ctx.get_messages()]
|
|
262
|
+
assert roles == ["system", "user", "assistant"], roles
|
|
263
|
+
|
|
264
|
+
# 2. Tool auto (list_dir) jalan sendiri, lalu selesai
|
|
265
|
+
fake = FakeLLM([
|
|
266
|
+
StreamDone("cek dulu ", [_TCR("c1", "list_dir", {"path": "."})]),
|
|
267
|
+
StreamDone("oke", []),
|
|
268
|
+
])
|
|
269
|
+
ctx = ConversationContext()
|
|
270
|
+
events = await _drain(run_agent("ls", ctx, cfg, llm_client=fake,
|
|
271
|
+
confirm=_no_confirm))
|
|
272
|
+
kinds = [type(e).__name__ for e in events]
|
|
273
|
+
assert kinds == ["AgentText", "AgentText", "AgentToolStart", "AgentToolDone",
|
|
274
|
+
"AgentText", "AgentDone"], kinds
|
|
275
|
+
assert events[3].success is True
|
|
276
|
+
tool_msgs = [m for m in ctx.get_messages() if m["role"] == "tool"]
|
|
277
|
+
assert len(tool_msgs) == 1 and tool_msgs[0]["tool_call_id"] == "c1"
|
|
278
|
+
# assistant message membawa tool_calls format OpenAI
|
|
279
|
+
asst = [m for m in ctx.get_messages() if m.get("tool_calls")]
|
|
280
|
+
assert asst and asst[0]["tool_calls"][0]["function"]["name"] == "list_dir"
|
|
281
|
+
|
|
282
|
+
# 3. Tool ask (write_file) ditolak → tidak dieksekusi
|
|
283
|
+
async def _deny(name, params):
|
|
284
|
+
return "no"
|
|
285
|
+
|
|
286
|
+
fake = FakeLLM([
|
|
287
|
+
StreamDone("", [_TCR("c1", "write_file", {"path": "x.txt", "content": "x"})]),
|
|
288
|
+
StreamDone("baiklah", []),
|
|
289
|
+
])
|
|
290
|
+
ctx = ConversationContext()
|
|
291
|
+
events = await _drain(run_agent("tulis", ctx, cfg, llm_client=fake, confirm=_deny))
|
|
292
|
+
assert events[0].name == "write_file" and events[1].success is False
|
|
293
|
+
assert "Dibatalkan user" in ctx.get_messages()[-2]["content"]
|
|
294
|
+
|
|
295
|
+
# 4. "all" = sesi ini auto seterusnya
|
|
296
|
+
answers = iter(["all"])
|
|
297
|
+
|
|
298
|
+
async def _all(name, params):
|
|
299
|
+
return next(answers)
|
|
300
|
+
|
|
301
|
+
fake = FakeLLM([
|
|
302
|
+
StreamDone("", [_TCR("c1", "bash", {"command": "echo a"})]),
|
|
303
|
+
StreamDone("", [_TCR("c2", "bash", {"command": "echo b"})]),
|
|
304
|
+
StreamDone("selesai", []),
|
|
305
|
+
])
|
|
306
|
+
ctx = ConversationContext()
|
|
307
|
+
events = await _drain(run_agent("run", ctx, cfg, llm_client=fake, confirm=_all))
|
|
308
|
+
dones = [e for e in events if isinstance(e, AgentToolDone)]
|
|
309
|
+
assert [d.success for d in dones] == [True, True], dones
|
|
310
|
+
|
|
311
|
+
# 5. Tool halusinasi → deny, loop lanjut
|
|
312
|
+
fake = FakeLLM([
|
|
313
|
+
StreamDone("", [_TCR("c1", "hancurkan_semua", {})]),
|
|
314
|
+
StreamDone("maaf", []),
|
|
315
|
+
])
|
|
316
|
+
ctx = ConversationContext()
|
|
317
|
+
events = await _drain(run_agent("x", ctx, cfg, llm_client=fake, confirm=_no_confirm))
|
|
318
|
+
assert events[1].success is False
|
|
319
|
+
assert "tidak tersedia" in ctx.get_messages()[-2]["content"]
|
|
320
|
+
assert isinstance(events[-1], AgentDone)
|
|
321
|
+
|
|
322
|
+
# 6. Infinite loop → berhenti di batas
|
|
323
|
+
cfg2 = Config(model="m", api_key="k", max_tool_iterations=3)
|
|
324
|
+
fake = FakeLLM([StreamDone("", [_TCR("c9", "list_dir", {})])] * 10)
|
|
325
|
+
ctx = ConversationContext()
|
|
326
|
+
events = await _drain(run_agent("loop", ctx, cfg2, llm_client=fake,
|
|
327
|
+
confirm=_no_confirm))
|
|
328
|
+
assert isinstance(events[-1], AgentError) and "3 iterasi" in events[-1].message
|
|
329
|
+
|
|
330
|
+
# 7. Tool ask() dicegat via callback (tanpa stdin)
|
|
331
|
+
async def _answer(q):
|
|
332
|
+
assert "nama" in q
|
|
333
|
+
return "budi"
|
|
334
|
+
|
|
335
|
+
fake = FakeLLM([
|
|
336
|
+
StreamDone("", [_TCR("c1", "ask", {"question": "siapa nama?"})]),
|
|
337
|
+
StreamDone("hai budi", []),
|
|
338
|
+
])
|
|
339
|
+
ctx = ConversationContext()
|
|
340
|
+
events = await _drain(run_agent("tanya", ctx, cfg, llm_client=fake,
|
|
341
|
+
confirm=_no_confirm, ask_user=_answer))
|
|
342
|
+
assert events[1].success is True
|
|
343
|
+
assert "budi" in ctx.get_messages()[-2]["content"]
|
|
344
|
+
|
|
345
|
+
# 8. Slash command dicegat — LLM tidak dipanggil, history bersih
|
|
346
|
+
from core.mode_manager import ModeManager as _MM
|
|
347
|
+
cfg_cmd = Config(model="m", api_key="k")
|
|
348
|
+
mm = _MM(config=cfg_cmd)
|
|
349
|
+
fake_empty = FakeLLM([]) # script kosong → error kalau LLM dipanggil
|
|
350
|
+
ctx = ConversationContext()
|
|
351
|
+
events = await _drain(run_agent("/help", ctx, cfg_cmd,
|
|
352
|
+
llm_client=fake_empty, mode_manager=mm))
|
|
353
|
+
assert isinstance(events[-1], AgentDone) and "/code" in events[-1].text
|
|
354
|
+
assert ctx.get_messages() == [], ctx.get_messages() # command tak masuk history
|
|
355
|
+
# /clear mengosongkan history lalu isi ulang system prompt
|
|
356
|
+
ctx.add_message("user", "x")
|
|
357
|
+
events = await _drain(run_agent("/clear", ctx, cfg_cmd,
|
|
358
|
+
llm_client=fake_empty, mode_manager=mm))
|
|
359
|
+
assert [m["role"] for m in ctx.get_messages()] == ["system"]
|
|
360
|
+
# /model ganti model on-the-fly
|
|
361
|
+
events = await _drain(run_agent("/model openai/gpt-4o", ctx, cfg_cmd,
|
|
362
|
+
llm_client=fake_empty, mode_manager=mm))
|
|
363
|
+
assert cfg_cmd.model == "openai/gpt-4o" and "gpt-4o" in events[-1].text
|
|
364
|
+
# bukan command → tetap ke LLM seperti biasa
|
|
365
|
+
fake2 = FakeLLM([StreamDone("ok", [])])
|
|
366
|
+
events = await _drain(run_agent("halo", ctx, cfg_cmd,
|
|
367
|
+
llm_client=fake2, mode_manager=mm))
|
|
368
|
+
assert isinstance(events[-1], AgentDone) and "ok" in events[-1].text
|
|
369
|
+
|
|
370
|
+
# 9. active_tools memfilter definisi tool yang dilihat LLM
|
|
371
|
+
seen_tools: list = []
|
|
372
|
+
|
|
373
|
+
class RecLLM(FakeLLM):
|
|
374
|
+
async def stream_completion(self, messages, tools=None):
|
|
375
|
+
seen_tools.extend(t["function"]["name"] for t in (tools or []))
|
|
376
|
+
async for e in super().stream_completion(messages, tools):
|
|
377
|
+
yield e
|
|
378
|
+
|
|
379
|
+
fake3 = RecLLM([StreamDone("ok", [])])
|
|
380
|
+
ctx = ConversationContext()
|
|
381
|
+
await _drain(run_agent("hi", ctx, cfg_cmd, llm_client=fake3,
|
|
382
|
+
active_tools=["list_dir", "bash"]))
|
|
383
|
+
assert seen_tools == ["list_dir", "bash"], seen_tools
|
|
384
|
+
|
|
385
|
+
# 10. Composer → system message tersusun soul → ctx → mode → rules
|
|
386
|
+
from core.prompt_composer import PromptComposer as _PC
|
|
387
|
+
pc = _PC(soul="SOULKU")
|
|
388
|
+
pc.update_project_ctx("CTXKU")
|
|
389
|
+
pc.update_mode("MODEKU")
|
|
390
|
+
fake4 = FakeLLM([StreamDone("ok", [])])
|
|
391
|
+
ctx = ConversationContext()
|
|
392
|
+
await _drain(run_agent("hi", ctx, cfg_cmd, llm_client=fake4, composer=pc))
|
|
393
|
+
sys_msg = ctx.get_messages()[0]
|
|
394
|
+
assert sys_msg["role"] == "system", sys_msg
|
|
395
|
+
idx = [sys_msg["content"].index(x)
|
|
396
|
+
for x in ("SOULKU", "CTXKU", "MODEKU", "Operational rules")]
|
|
397
|
+
assert idx == sorted(idx), sys_msg["content"][:200]
|
|
398
|
+
# /clear isi ulang dengan prompt tersusun, bukan hardcoded
|
|
399
|
+
mm2 = _MM(config=cfg_cmd)
|
|
400
|
+
await _drain(run_agent("/clear", ctx, cfg_cmd, llm_client=FakeLLM([]),
|
|
401
|
+
mode_manager=mm2, composer=pc))
|
|
402
|
+
assert ctx.get_messages()[0]["content"].startswith("SOULKU"), \
|
|
403
|
+
ctx.get_messages()[0]["content"][:100]
|
|
404
|
+
|
|
405
|
+
print("✅ agent_loop self-test OK (10 skenario)")
|
|
406
|
+
|
|
407
|
+
async def _no_confirm(name, params):
|
|
408
|
+
raise AssertionError(f"tidak boleh minta konfirmasi untuk {name}")
|
|
409
|
+
|
|
410
|
+
_asyncio.run(main())
|