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.
Files changed (135) hide show
  1. briefing/__init__.py +6 -0
  2. briefing/generator.py +125 -0
  3. briefing/privacy.py +112 -0
  4. briefing/sources/__init__.py +3 -0
  5. briefing/sources/git_source.py +77 -0
  6. briefing/sources/news_source.py +76 -0
  7. briefing/sources/todo_source.py +69 -0
  8. core/__init__.py +0 -0
  9. core/agent_loop.py +410 -0
  10. core/codebase.py +318 -0
  11. core/config.py +340 -0
  12. core/llm_client.py +347 -0
  13. core/mode_manager.py +342 -0
  14. core/permissions.py +284 -0
  15. core/plugin_loader.py +194 -0
  16. core/prompt_composer.py +126 -0
  17. core/research/__init__.py +0 -0
  18. core/research/bus.py +41 -0
  19. core/research/orchestrator.py +638 -0
  20. core/research/query_generator.py +199 -0
  21. core/updater.py +187 -0
  22. core/version.py +20 -0
  23. daemon/__init__.py +3 -0
  24. daemon/ipc.py +127 -0
  25. daemon/process.py +205 -0
  26. main.py +195 -0
  27. memory/__init__.py +0 -0
  28. memory/context.py +117 -0
  29. memory/store.py +78 -0
  30. multacd-1.0.0.dist-info/METADATA +94 -0
  31. multacd-1.0.0.dist-info/RECORD +135 -0
  32. multacd-1.0.0.dist-info/WHEEL +4 -0
  33. multacd-1.0.0.dist-info/entry_points.txt +2 -0
  34. multacd-1.0.0.dist-info/licenses/LICENSE +21 -0
  35. scheduler/__init__.py +3 -0
  36. scheduler/engine.py +227 -0
  37. scheduler/jobs/__init__.py +37 -0
  38. scheduler/jobs/briefing_job.py +36 -0
  39. scheduler/jobs/research_job.py +43 -0
  40. search_providers/__init__.py +41 -0
  41. search_providers/__main__.py +43 -0
  42. search_providers/base.py +137 -0
  43. search_providers/brave.py +87 -0
  44. search_providers/duckduckgo.py +114 -0
  45. search_providers/exa.py +99 -0
  46. search_providers/serpapi.py +85 -0
  47. search_providers/tavily.py +79 -0
  48. soul.md +30 -0
  49. tg/__init__.py +7 -0
  50. tg/access_control.py +131 -0
  51. tg/agent.py +268 -0
  52. tg/bot.py +79 -0
  53. tg/file_handler.py +152 -0
  54. tg/formatter.py +94 -0
  55. tg/handlers.py +358 -0
  56. tools/__init__.py +0 -0
  57. tools/agent/__init__.py +0 -0
  58. tools/agent/ask.py +35 -0
  59. tools/agent/skill.py +67 -0
  60. tools/agent/task.py +33 -0
  61. tools/agent/todo_write.py +54 -0
  62. tools/code/__init__.py +0 -0
  63. tools/code/lint_python.py +144 -0
  64. tools/code/run_python.py +169 -0
  65. tools/code/run_tests.py +156 -0
  66. tools/codebase/__init__.py +0 -0
  67. tools/codebase/scan_codebase.py +28 -0
  68. tools/common.py +18 -0
  69. tools/filesystem/__init__.py +0 -0
  70. tools/filesystem/apply_patch.py +89 -0
  71. tools/filesystem/delete_file.py +36 -0
  72. tools/filesystem/edit_file.py +42 -0
  73. tools/filesystem/glob.py +35 -0
  74. tools/filesystem/grep.py +78 -0
  75. tools/filesystem/list_dir.py +46 -0
  76. tools/filesystem/move_file.py +38 -0
  77. tools/filesystem/multi_edit.py +46 -0
  78. tools/filesystem/read_file.py +42 -0
  79. tools/filesystem/read_many_files.py +35 -0
  80. tools/filesystem/write_file.py +37 -0
  81. tools/git/__init__.py +0 -0
  82. tools/git/_helper.py +44 -0
  83. tools/git/git_add.py +21 -0
  84. tools/git/git_branch.py +13 -0
  85. tools/git/git_checkout.py +25 -0
  86. tools/git/git_commit.py +21 -0
  87. tools/git/git_diff.py +18 -0
  88. tools/git/git_log.py +17 -0
  89. tools/git/git_merge.py +140 -0
  90. tools/git/git_pull.py +17 -0
  91. tools/git/git_push.py +92 -0
  92. tools/git/git_status.py +13 -0
  93. tools/memory/__init__.py +0 -0
  94. tools/memory/forget.py +33 -0
  95. tools/memory/recall.py +35 -0
  96. tools/memory/remember.py +34 -0
  97. tools/personal/__init__.py +3 -0
  98. tools/personal/cancel_job.py +55 -0
  99. tools/personal/daemon_status.py +64 -0
  100. tools/personal/generate_briefing.py +93 -0
  101. tools/personal/get_jobs.py +52 -0
  102. tools/personal/schedule_job.py +77 -0
  103. tools/personal/send_telegram.py +171 -0
  104. tools/personal/user_manager.py +103 -0
  105. tools/registry.py +286 -0
  106. tools/research/__init__.py +0 -0
  107. tools/research/deep_research.py +148 -0
  108. tools/research/export_research.py +146 -0
  109. tools/research/quick_research.py +159 -0
  110. tools/research/web_scrape.py +172 -0
  111. tools/research/web_search.py +176 -0
  112. tools/shell/__init__.py +0 -0
  113. tools/shell/bash.py +60 -0
  114. tools/web/__init__.py +0 -0
  115. tools/web/web_fetch.py +66 -0
  116. tui/__init__.py +0 -0
  117. tui/app.py +113 -0
  118. tui/icons.py +114 -0
  119. tui/screens/__init__.py +0 -0
  120. tui/screens/main_screen.py +424 -0
  121. tui/screens/setup_wizard.py +483 -0
  122. tui/themes/__init__.py +133 -0
  123. tui/themes/__main__.py +17 -0
  124. tui/widgets/__init__.py +0 -0
  125. tui/widgets/chat_panel.py +83 -0
  126. tui/widgets/confirm_dialog.py +52 -0
  127. tui/widgets/diff_viewer.py +68 -0
  128. tui/widgets/file_tree.py +121 -0
  129. tui/widgets/input_bar.py +92 -0
  130. tui/widgets/permission_popup.py +172 -0
  131. tui/widgets/slash_palette.py +131 -0
  132. tui/widgets/sources_panel.py +201 -0
  133. tui/widgets/status_bar.py +70 -0
  134. tui/widgets/thinking_bar.py +106 -0
  135. 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())