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/llm_client.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""LLM client โ wrapper LiteLLM: streaming + tool call + retry.
|
|
2
|
+
|
|
3
|
+
Dipakai oleh agent loop (Step 7) lewat async generator:
|
|
4
|
+
|
|
5
|
+
client = setup_client(config)
|
|
6
|
+
async for event in client.stream_completion(messages, tools):
|
|
7
|
+
if isinstance(event, StreamText):
|
|
8
|
+
... tampilkan event.content ke TUI ...
|
|
9
|
+
elif isinstance(event, StreamDone):
|
|
10
|
+
... event.text = teks penuh, event.tool_calls = [ToolCallRequest] ...
|
|
11
|
+
|
|
12
|
+
Test cepat tanpa API key (logika akumulasi + error mapping):
|
|
13
|
+
python -m core.llm_client
|
|
14
|
+
Test live (butuh ~/.multacd/config.yaml yang valid):
|
|
15
|
+
python -m core.llm_client --live "hello"
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
from collections.abc import AsyncIterator
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import litellm
|
|
28
|
+
from litellm import (
|
|
29
|
+
APIConnectionError,
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
RateLimitError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from core.config import Config, load_config
|
|
36
|
+
|
|
37
|
+
MAX_CONNECTION_RETRIES = 3
|
|
38
|
+
MAX_RATE_LIMIT_RETRIES = 3
|
|
39
|
+
RETRY_BASE_DELAY = 2.0 # detik, exponential backoff
|
|
40
|
+
REQUEST_TIMEOUT = 120.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LLMError(Exception):
|
|
44
|
+
"""Error LLM dengan pesan yang friendly โ tampilkan langsung ke user."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ToolCallRequest:
|
|
49
|
+
"""Satu tool call yang diminta LLM (sudah dirakit dari fragment streaming)."""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
name: str
|
|
53
|
+
arguments: dict[str, Any]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class StreamText:
|
|
58
|
+
"""Potongan teks baru dari LLM (tampilkan real-time ke TUI)."""
|
|
59
|
+
|
|
60
|
+
content: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class StreamDone:
|
|
65
|
+
"""Stream selesai: teks penuh + daftar tool call (bisa kosong)."""
|
|
66
|
+
|
|
67
|
+
text: str
|
|
68
|
+
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
StreamEvent = StreamText | StreamDone
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def setup_client(config: Config) -> LLMClient:
|
|
75
|
+
"""Init client dari config (LiteLLM stateless โ ini bungkus kwargs + retry)."""
|
|
76
|
+
return LLMClient(config)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LLMClient:
|
|
80
|
+
def __init__(self, config: Config) -> None:
|
|
81
|
+
self.config = config
|
|
82
|
+
|
|
83
|
+
def _base_kwargs(self) -> dict[str, Any]:
|
|
84
|
+
kwargs: dict[str, Any] = {
|
|
85
|
+
"model": self.config.model,
|
|
86
|
+
"api_key": self.config.api_key,
|
|
87
|
+
"max_tokens": self.config.max_tokens,
|
|
88
|
+
"temperature": self.config.temperature,
|
|
89
|
+
"timeout": REQUEST_TIMEOUT,
|
|
90
|
+
}
|
|
91
|
+
if self.config.api_base:
|
|
92
|
+
kwargs["api_base"] = self.config.api_base
|
|
93
|
+
return kwargs
|
|
94
|
+
|
|
95
|
+
async def stream_completion(
|
|
96
|
+
self,
|
|
97
|
+
messages: list[dict[str, Any]],
|
|
98
|
+
tools: list[dict[str, Any]] | None = None,
|
|
99
|
+
) -> AsyncIterator[StreamEvent]:
|
|
100
|
+
"""Kirim messages ke LLM, yield StreamText lalu diakhiri satu StreamDone.
|
|
101
|
+
|
|
102
|
+
Retry hanya dilakukan jika stream BELUM menghasilkan teks apapun
|
|
103
|
+
(aman โ tidak ada duplikasi output). Gagal di tengah stream
|
|
104
|
+
langsung raise LLMError.
|
|
105
|
+
"""
|
|
106
|
+
kwargs = self._base_kwargs()
|
|
107
|
+
if tools:
|
|
108
|
+
kwargs["tools"] = tools
|
|
109
|
+
|
|
110
|
+
conn_attempts = 0
|
|
111
|
+
rate_attempts = 0
|
|
112
|
+
while True:
|
|
113
|
+
accumulator = _StreamAccumulator()
|
|
114
|
+
yielded_any_text = False
|
|
115
|
+
try:
|
|
116
|
+
stream = await litellm.acompletion(stream=True, messages=messages, **kwargs)
|
|
117
|
+
async for chunk in stream:
|
|
118
|
+
for event in accumulator.feed(chunk):
|
|
119
|
+
if isinstance(event, StreamText) and event.content:
|
|
120
|
+
yielded_any_text = True
|
|
121
|
+
yield event
|
|
122
|
+
yield accumulator.done()
|
|
123
|
+
return
|
|
124
|
+
except RateLimitError as e:
|
|
125
|
+
rate_attempts += 1
|
|
126
|
+
if rate_attempts > MAX_RATE_LIMIT_RETRIES or yielded_any_text:
|
|
127
|
+
raise LLMError(
|
|
128
|
+
"โณ Rate limit dari provider (429) โ "
|
|
129
|
+
f"sudah retry {MAX_RATE_LIMIT_RETRIES}x, masih dibatasi. "
|
|
130
|
+
"Tunggu sebentar lalu coba lagi."
|
|
131
|
+
) from e
|
|
132
|
+
await asyncio.sleep(RETRY_BASE_DELAY * (2 ** (rate_attempts - 1)))
|
|
133
|
+
except APIConnectionError as e:
|
|
134
|
+
conn_attempts += 1
|
|
135
|
+
if conn_attempts > MAX_CONNECTION_RETRIES or yielded_any_text:
|
|
136
|
+
raise LLMError(
|
|
137
|
+
"๐ Gagal konek ke LLM provider "
|
|
138
|
+
f"({MAX_CONNECTION_RETRIES}x percobaan). "
|
|
139
|
+
"Cek koneksi internet / api_base di config."
|
|
140
|
+
) from e
|
|
141
|
+
await asyncio.sleep(RETRY_BASE_DELAY * conn_attempts)
|
|
142
|
+
except AuthenticationError as e:
|
|
143
|
+
raise LLMError(
|
|
144
|
+
"๐ API key ditolak provider. Cek `api_key` di ~/.multacd/config.yaml "
|
|
145
|
+
"(BYOK โ pastikan key cocok dengan `model` yang dipilih)."
|
|
146
|
+
) from e
|
|
147
|
+
except NotFoundError as e:
|
|
148
|
+
raise LLMError(
|
|
149
|
+
f"๐ Model `{self.config.model}` tidak ditemukan oleh provider. "
|
|
150
|
+
"Cek penulisan `model` di config (format LiteLLM, mis. "
|
|
151
|
+
"`anthropic/claude-sonnet-4-6`, `openai/gpt-4o`)."
|
|
152
|
+
) from e
|
|
153
|
+
except LLMError:
|
|
154
|
+
raise
|
|
155
|
+
except Exception as e:
|
|
156
|
+
raise LLMError(f"โ LLM error tak terduga ({type(e).__name__}): {e}") from e
|
|
157
|
+
|
|
158
|
+
async def complete(
|
|
159
|
+
self,
|
|
160
|
+
messages: list[dict[str, Any]],
|
|
161
|
+
tools: list[dict[str, Any]] | None = None,
|
|
162
|
+
) -> StreamDone:
|
|
163
|
+
"""Non-streaming: kumpulkan stream jadi satu hasil (buat test/debug)."""
|
|
164
|
+
full: StreamDone | None = None
|
|
165
|
+
async for event in self.stream_completion(messages, tools):
|
|
166
|
+
if isinstance(event, StreamDone):
|
|
167
|
+
full = event
|
|
168
|
+
assert full is not None
|
|
169
|
+
return full
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _get(obj: Any, key: str, default: Any = None) -> Any:
|
|
173
|
+
"""Ambil atribut ATAU key dict โ chunk LiteLLM bisa berbentuk keduanya."""
|
|
174
|
+
if obj is None:
|
|
175
|
+
return default
|
|
176
|
+
if isinstance(obj, dict):
|
|
177
|
+
return obj.get(key, default)
|
|
178
|
+
return getattr(obj, key, default)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _StreamAccumulator:
|
|
182
|
+
"""Rakit fragment streaming LiteLLM jadi teks + tool call utuh."""
|
|
183
|
+
|
|
184
|
+
def __init__(self) -> None:
|
|
185
|
+
self._text_parts: list[str] = []
|
|
186
|
+
self._calls: dict[int, dict[str, Any]] = {}
|
|
187
|
+
|
|
188
|
+
def feed(self, chunk: Any) -> list[StreamText]:
|
|
189
|
+
events: list[StreamText] = []
|
|
190
|
+
choices = _get(chunk, "choices", []) or []
|
|
191
|
+
if not choices:
|
|
192
|
+
return events
|
|
193
|
+
delta = _get(choices[0], "delta", {}) or {}
|
|
194
|
+
|
|
195
|
+
content = _get(delta, "content")
|
|
196
|
+
if content:
|
|
197
|
+
self._text_parts.append(content)
|
|
198
|
+
events.append(StreamText(content))
|
|
199
|
+
|
|
200
|
+
for tc in _get(delta, "tool_calls", None) or []:
|
|
201
|
+
index = _get(tc, "index", 0) or 0
|
|
202
|
+
slot = self._calls.setdefault(index, {"id": "", "name": "", "args": ""})
|
|
203
|
+
tc_id = _get(tc, "id")
|
|
204
|
+
if tc_id:
|
|
205
|
+
slot["id"] = tc_id
|
|
206
|
+
func = _get(tc, "function", {}) or {}
|
|
207
|
+
name = _get(func, "name")
|
|
208
|
+
if name:
|
|
209
|
+
slot["name"] = name
|
|
210
|
+
args = _get(func, "arguments")
|
|
211
|
+
if args:
|
|
212
|
+
slot["args"] += args
|
|
213
|
+
return events
|
|
214
|
+
|
|
215
|
+
def done(self) -> StreamDone:
|
|
216
|
+
calls: list[ToolCallRequest] = []
|
|
217
|
+
for index in sorted(self._calls):
|
|
218
|
+
slot = self._calls[index]
|
|
219
|
+
name = slot["name"]
|
|
220
|
+
if not name:
|
|
221
|
+
continue # fragment tanpa nama โ abaikan
|
|
222
|
+
raw_args = slot["args"] or "{}"
|
|
223
|
+
try:
|
|
224
|
+
arguments = json.loads(raw_args)
|
|
225
|
+
except json.JSONDecodeError as e:
|
|
226
|
+
raise LLMError(
|
|
227
|
+
f"โ LLM mengirim tool_call `{name}` dengan argumen bukan JSON valid: {e}"
|
|
228
|
+
) from e
|
|
229
|
+
if not isinstance(arguments, dict):
|
|
230
|
+
raise LLMError(
|
|
231
|
+
f"โ Argumen tool_call `{name}` harus object JSON, dapat: {type(arguments).__name__}"
|
|
232
|
+
)
|
|
233
|
+
calls.append(ToolCallRequest(id=slot["id"] or f"call_{index}", name=name, arguments=arguments))
|
|
234
|
+
return StreamDone(text="".join(self._text_parts), tool_calls=calls)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# โโ Self-test (tanpa API key) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
238
|
+
|
|
239
|
+
def _fake_chunk(text: str = "", tool_deltas: list[dict] | None = None) -> dict:
|
|
240
|
+
return {
|
|
241
|
+
"choices": [
|
|
242
|
+
{"delta": {"content": text, "tool_calls": tool_deltas or []}},
|
|
243
|
+
]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def _self_test() -> None:
|
|
248
|
+
# 1. Teks murni
|
|
249
|
+
acc = _StreamAccumulator()
|
|
250
|
+
events: list[StreamEvent] = []
|
|
251
|
+
for ch in [_fake_chunk("Halo, "), _fake_chunk("dunia!")]:
|
|
252
|
+
events += acc.feed(ch)
|
|
253
|
+
done = acc.done()
|
|
254
|
+
assert [e.content for e in events] == ["Halo, ", "dunia!"], events
|
|
255
|
+
assert done.text == "Halo, dunia!" and done.tool_calls == []
|
|
256
|
+
|
|
257
|
+
# 2. Tool call terpecah jadi fragment (kasus nyata streaming)
|
|
258
|
+
acc = _StreamAccumulator()
|
|
259
|
+
acc.feed(_fake_chunk("bentar, ", [{"index": 0, "id": "call_1",
|
|
260
|
+
"function": {"name": "read_file", "arguments": '{"path": "ma'}}]))
|
|
261
|
+
acc.feed(_fake_chunk("", [{"index": 0, "function": {"arguments": 'in.py"}'}}]))
|
|
262
|
+
acc.feed(_fake_chunk("", [{"index": 1, "id": "call_2",
|
|
263
|
+
"function": {"name": "bash", "arguments": '{"command": "ls"}'}}]))
|
|
264
|
+
done = acc.done()
|
|
265
|
+
assert done.text == "bentar, ", done.text
|
|
266
|
+
assert len(done.tool_calls) == 2, done.tool_calls
|
|
267
|
+
assert done.tool_calls[0].name == "read_file"
|
|
268
|
+
assert done.tool_calls[0].arguments == {"path": "main.py"}, done.tool_calls[0].arguments
|
|
269
|
+
assert done.tool_calls[1].name == "bash"
|
|
270
|
+
|
|
271
|
+
# 3. Argumen bukan JSON โ LLMError (bukan crash)
|
|
272
|
+
acc = _StreamAccumulator()
|
|
273
|
+
acc.feed(_fake_chunk("", [{"index": 0, "function": {"name": "x", "arguments": "{oops"}}]))
|
|
274
|
+
try:
|
|
275
|
+
acc.done()
|
|
276
|
+
except LLMError:
|
|
277
|
+
pass
|
|
278
|
+
else:
|
|
279
|
+
raise AssertionError("argumen invalid harus raise LLMError")
|
|
280
|
+
|
|
281
|
+
# 4. Error mapping: auth, not-found, retry koneksi 3x
|
|
282
|
+
from unittest.mock import patch
|
|
283
|
+
|
|
284
|
+
cfg = Config(model="openai/gpt-4o", api_key="sk-bad")
|
|
285
|
+
client = LLMClient(cfg)
|
|
286
|
+
|
|
287
|
+
async def _drain(c: LLMClient) -> StreamDone:
|
|
288
|
+
async for ev in c.stream_completion([{"role": "user", "content": "hi"}]):
|
|
289
|
+
if isinstance(ev, StreamDone):
|
|
290
|
+
return ev
|
|
291
|
+
raise AssertionError("unreachable")
|
|
292
|
+
|
|
293
|
+
with patch("core.llm_client.litellm.acompletion", side_effect=AuthenticationError("bad", "x", "x")):
|
|
294
|
+
try:
|
|
295
|
+
await _drain(client)
|
|
296
|
+
except LLMError as e:
|
|
297
|
+
assert "API key" in str(e), e
|
|
298
|
+
else:
|
|
299
|
+
raise AssertionError("auth error harus jadi LLMError")
|
|
300
|
+
|
|
301
|
+
with patch("core.llm_client.litellm.acompletion", side_effect=NotFoundError("nf", "x", "x")):
|
|
302
|
+
try:
|
|
303
|
+
await _drain(client)
|
|
304
|
+
except LLMError as e:
|
|
305
|
+
assert "tidak ditemukan" in str(e), e
|
|
306
|
+
else:
|
|
307
|
+
raise AssertionError("not-found harus jadi LLMError")
|
|
308
|
+
|
|
309
|
+
calls = {"n": 0}
|
|
310
|
+
|
|
311
|
+
async def _flaky(*a: Any, **k: Any) -> Any:
|
|
312
|
+
calls["n"] += 1
|
|
313
|
+
raise APIConnectionError("down", "x", "x")
|
|
314
|
+
|
|
315
|
+
with (patch("core.llm_client.litellm.acompletion", side_effect=_flaky),
|
|
316
|
+
patch("core.llm_client.asyncio.sleep", return_value=None)):
|
|
317
|
+
try:
|
|
318
|
+
await _drain(client)
|
|
319
|
+
except LLMError as e:
|
|
320
|
+
assert "Gagal konek" in str(e), e
|
|
321
|
+
else:
|
|
322
|
+
raise AssertionError("conn error harus jadi LLMError")
|
|
323
|
+
assert calls["n"] == MAX_CONNECTION_RETRIES + 1, calls
|
|
324
|
+
|
|
325
|
+
print("โ
llm_client self-test OK (akumulasi + error mapping + retry)")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async def _live_test(prompt: str) -> None:
|
|
329
|
+
cfg = load_config()
|
|
330
|
+
client = setup_client(cfg)
|
|
331
|
+
print(f"model: {cfg.model} โ streaming...\n---")
|
|
332
|
+
async for event in client.stream_completion([{"role": "user", "content": prompt}]):
|
|
333
|
+
if isinstance(event, StreamText):
|
|
334
|
+
print(event.content, end="", flush=True)
|
|
335
|
+
elif isinstance(event, StreamDone):
|
|
336
|
+
print(f"\n---\nโ
done. tool_calls={len(event.tool_calls)}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
if __name__ == "__main__":
|
|
340
|
+
parser = argparse.ArgumentParser()
|
|
341
|
+
parser.add_argument("--live", metavar="PROMPT", default=None,
|
|
342
|
+
help="test live ke provider (butuh config valid)")
|
|
343
|
+
args = parser.parse_args()
|
|
344
|
+
if args.live:
|
|
345
|
+
asyncio.run(_live_test(args.live))
|
|
346
|
+
else:
|
|
347
|
+
asyncio.run(_self_test())
|
core/mode_manager.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Mode manager โ mode agent + parse slash command.
|
|
2
|
+
|
|
3
|
+
Mode aktif:
|
|
4
|
+
code โ Coding Agent (penuh di Phase 2)
|
|
5
|
+
research โ Research Agent (penuh di Phase 3)
|
|
6
|
+
personal โ Personal Agent (penuh di Phase 4)
|
|
7
|
+
|
|
8
|
+
Command yang dikenal (lihat HELP_TEXT):
|
|
9
|
+
/code /research /personal /clear /scan /model [nama] /help /soul
|
|
10
|
+
|
|
11
|
+
Command di-intercept sebelum LLM (lihat core/agent_loop.py):
|
|
12
|
+
tidak masuk history, tidak panggil LLM.
|
|
13
|
+
|
|
14
|
+
Test cepat:
|
|
15
|
+
python -m core.mode_manager
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from core.config import Config
|
|
26
|
+
|
|
27
|
+
MODE_CODE = "code"
|
|
28
|
+
MODE_RESEARCH = "research"
|
|
29
|
+
MODE_PERSONAL = "personal"
|
|
30
|
+
|
|
31
|
+
HELP_TEXT = (
|
|
32
|
+
"๐ Command multacd:\n"
|
|
33
|
+
" /code โ mode Coding Agent (default)\n"
|
|
34
|
+
" /research โ mode Research Agent (riset internet + sumber)\n"
|
|
35
|
+
" /personal โ Personal Agent (bot, jadwal, briefing, daemon)\n"
|
|
36
|
+
" /clear โ bersihkan history, mulai sesi baru\n"
|
|
37
|
+
" /scan โ scan ulang codebase project\n"
|
|
38
|
+
" /model [nama] โ lihat / ganti model (cth: /model openai/gpt-4o)\n"
|
|
39
|
+
" /soul โ tampilkan kepribadian agent yang aktif\n"
|
|
40
|
+
" /help โ tampilkan pesan ini"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Single source buat slash palette (nama + deskripsi singkat).
|
|
44
|
+
PALETTE_COMMANDS: tuple[tuple[str, str], ...] = (
|
|
45
|
+
("/code", "Switch to Coding Agent mode"),
|
|
46
|
+
("/research", "Switch to Research Agent mode"),
|
|
47
|
+
("/personal", "Switch to Personal Agent mode"),
|
|
48
|
+
("/clear", "Clear conversation history"),
|
|
49
|
+
("/scan", "Re-scan codebase"),
|
|
50
|
+
("/model", "Switch LLM model"),
|
|
51
|
+
("/soul", "Show active soul.md"),
|
|
52
|
+
("/help", "Show all commands"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Command yang butuh argumen lanjutan โ Enter = autocomplete, bukan submit.
|
|
56
|
+
COMMANDS_WITH_ARGS = frozenset({"/model"})
|
|
57
|
+
|
|
58
|
+
MODE_PROMPTS = {
|
|
59
|
+
MODE_CODE: (
|
|
60
|
+
"Kamu sedang dalam Coding Agent mode. "
|
|
61
|
+
"Kamu punya akses ke: filesystem, shell, git, "
|
|
62
|
+
"run_python, lint_python, run_tests. "
|
|
63
|
+
"Fokus membantu user dengan kode dan development workflow. "
|
|
64
|
+
"Smart commit flow: user minta commit โ jalankan git_status + git_diff dulu, "
|
|
65
|
+
"tulis pesan conventional commits (feat/fix/refactor/docs/chore), "
|
|
66
|
+
"lalu git_add + git_commit (dialog konfirmasi = persetujuan user). "
|
|
67
|
+
"Jangan push ke branch main/master/production/prod/release/stable "
|
|
68
|
+
"tanpa izin eksplisit โ kalau tool menolak, tawarkan ke user: "
|
|
69
|
+
"tetap push, buat branch baru, atau batal. "
|
|
70
|
+
"Kalau merge conflict: analisis ours-vs-theirs, suggest resolusi, "
|
|
71
|
+
"apply via edit_file + git_add hanya setelah user setuju. "
|
|
72
|
+
"Kalau run_python/lint_python/run_tests gagal atau error: baca output-nya, "
|
|
73
|
+
"jelaskan penyebab ke user, suggest fix โ jangan auto-fix tanpa izin."
|
|
74
|
+
),
|
|
75
|
+
MODE_RESEARCH: (
|
|
76
|
+
"Kamu sedang dalam Research Agent mode. "
|
|
77
|
+
"Kamu punya akses ke: web_search, web_scrape, quick_research, "
|
|
78
|
+
"deep_research, export_research. "
|
|
79
|
+
"Panduan: untuk pertanyaan butuh jawaban cepat dengan sumber โ "
|
|
80
|
+
"quick_research; untuk topik kompleks butuh laporan mendalam โ "
|
|
81
|
+
"deep_research. Selalu kutip sumber dalam jawaban. "
|
|
82
|
+
"Kalau scraping gagal, gunakan snippet dan beritahu user. "
|
|
83
|
+
"Tawarkan export ke .md setelah research selesai. "
|
|
84
|
+
"Deteksi dan flag informasi yang kontradiktif antar sumber."
|
|
85
|
+
),
|
|
86
|
+
MODE_PERSONAL: (
|
|
87
|
+
"Kamu sedang dalam Personal Agent mode. "
|
|
88
|
+
"Kamu punya akses ke: send_telegram, schedule_job, cancel_job, "
|
|
89
|
+
"get_jobs, daemon_status, user_manager, generate_briefing, "
|
|
90
|
+
"web_search, quick_research, read tools, git_status, remember, "
|
|
91
|
+
"recall. "
|
|
92
|
+
"Tugasmu: kelola bot Telegram (tambah/hapus user), kelola scheduled "
|
|
93
|
+
"jobs, cek status daemon, buat & kirim briefing, kirim pesan manual. "
|
|
94
|
+
"Aturan: aksi berdampak (tambah/hapus user, schedule/cancel, kirim "
|
|
95
|
+
"pesan/file, generate briefing) selalu konfirmasi dulu โ tool sudah "
|
|
96
|
+
"ASK, hormati jawaban user. "
|
|
97
|
+
"'Kirim briefing sekarang' = konfirmasi, lalu generate_briefing "
|
|
98
|
+
"(send_to_admin=True kalau user mau dikirim ke Telegram). "
|
|
99
|
+
"Jangan panggil tool yang tidak ada di daftar."
|
|
100
|
+
),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Tool aktif di mode /personal: personal tools + baca + riset ringan.
|
|
104
|
+
# Tulis/shell/eksekusi TIDAK ada โ mode ini dashboard manajemen, aman.
|
|
105
|
+
PERSONAL_MODE_TOOLS = [
|
|
106
|
+
"send_telegram",
|
|
107
|
+
"schedule_job",
|
|
108
|
+
"cancel_job",
|
|
109
|
+
"get_jobs",
|
|
110
|
+
"daemon_status",
|
|
111
|
+
"user_manager",
|
|
112
|
+
"generate_briefing",
|
|
113
|
+
"web_search",
|
|
114
|
+
"quick_research",
|
|
115
|
+
"read_file",
|
|
116
|
+
"read_many_files",
|
|
117
|
+
"glob",
|
|
118
|
+
"grep",
|
|
119
|
+
"list_dir",
|
|
120
|
+
"scan_codebase",
|
|
121
|
+
"git_status",
|
|
122
|
+
"remember",
|
|
123
|
+
"recall",
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# Tool aktif di mode /research: research tools + read tools Phase 1
|
|
127
|
+
# (web_fetch, read_file, dll tetap aktif โ lihat PLAN-phase3 ยง10).
|
|
128
|
+
RESEARCH_MODE_TOOLS = [
|
|
129
|
+
"web_search",
|
|
130
|
+
"web_scrape",
|
|
131
|
+
"quick_research",
|
|
132
|
+
"deep_research",
|
|
133
|
+
"export_research",
|
|
134
|
+
"read_file",
|
|
135
|
+
"read_many_files",
|
|
136
|
+
"glob",
|
|
137
|
+
"grep",
|
|
138
|
+
"list_dir",
|
|
139
|
+
"scan_codebase",
|
|
140
|
+
"web_fetch",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class CommandResult:
|
|
146
|
+
"""Hasil parse slash command.
|
|
147
|
+
|
|
148
|
+
is_command False โ bukan command, teruskan ke LLM seperti biasa.
|
|
149
|
+
action: "clear" | "scan" | "mode" | None โ aksi UI yang harus
|
|
150
|
+
dilakukan caller (agent_loop handle clear; TUI sync mode/model).
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
is_command: bool
|
|
154
|
+
message: str = ""
|
|
155
|
+
action: str | None = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ModeManager:
|
|
159
|
+
"""Pegang mode aktif + parse /command. Default: /code.
|
|
160
|
+
|
|
161
|
+
`rescan_fn`: callback () -> str pesan display; dipasang app TUI
|
|
162
|
+
agar /scan refresh project context + composer (lihat tui/app.py).
|
|
163
|
+
Tanpa itu, /scan cuma return sinyal tanpa eksekusi.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(self, config: Config | None = None, soul: str = "",
|
|
167
|
+
rescan_fn: Callable[[], str] | None = None) -> None:
|
|
168
|
+
self._mode = MODE_CODE
|
|
169
|
+
self._config = config
|
|
170
|
+
self._soul = soul
|
|
171
|
+
self._rescan_fn = rescan_fn
|
|
172
|
+
self._git_enabled = True
|
|
173
|
+
|
|
174
|
+
def set_git_enabled(self, enabled: bool) -> None:
|
|
175
|
+
"""Matikan kalau folder bukan git repo โ tool git disembunyikan."""
|
|
176
|
+
self._git_enabled = enabled
|
|
177
|
+
|
|
178
|
+
def get_mode(self) -> str:
|
|
179
|
+
return self._mode
|
|
180
|
+
|
|
181
|
+
def set_soul(self, soul: str) -> None:
|
|
182
|
+
self._soul = soul
|
|
183
|
+
|
|
184
|
+
def set_mode(self, mode: str) -> str:
|
|
185
|
+
if mode not in (MODE_CODE, MODE_RESEARCH, MODE_PERSONAL):
|
|
186
|
+
return f"โ Mode tidak dikenal: {mode}. Pilihan: /code /research /personal"
|
|
187
|
+
self._mode = mode
|
|
188
|
+
return f"โ
Mode: {mode}"
|
|
189
|
+
|
|
190
|
+
def get_mode_prompt(self) -> str:
|
|
191
|
+
return MODE_PROMPTS[self._mode]
|
|
192
|
+
|
|
193
|
+
def get_active_tools(self) -> list[str] | None:
|
|
194
|
+
"""None = semua tool aktif. Bukan git repo โ list tanpa tool git.
|
|
195
|
+
|
|
196
|
+
Mode /research โ list eksplisit (research + read tools).
|
|
197
|
+
"""
|
|
198
|
+
if self._mode == MODE_RESEARCH:
|
|
199
|
+
return list(RESEARCH_MODE_TOOLS)
|
|
200
|
+
if self._mode == MODE_PERSONAL:
|
|
201
|
+
return list(PERSONAL_MODE_TOOLS)
|
|
202
|
+
if self._git_enabled:
|
|
203
|
+
return None
|
|
204
|
+
from core.permissions import GIT_TOOLS, KNOWN_TOOLS
|
|
205
|
+
return sorted(KNOWN_TOOLS - GIT_TOOLS)
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def is_command(text: str) -> bool:
|
|
209
|
+
return text.strip().startswith("/")
|
|
210
|
+
|
|
211
|
+
def handle_command(self, user_input: str) -> CommandResult:
|
|
212
|
+
text = user_input.strip()
|
|
213
|
+
if not text.startswith("/"):
|
|
214
|
+
return CommandResult(is_command=False)
|
|
215
|
+
parts = text.split(None, 1)
|
|
216
|
+
cmd = parts[0].lower()
|
|
217
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
218
|
+
|
|
219
|
+
if cmd == "/code":
|
|
220
|
+
return CommandResult(True, self.set_mode(MODE_CODE), "mode")
|
|
221
|
+
if cmd == "/research":
|
|
222
|
+
msg = self.set_mode(MODE_RESEARCH)
|
|
223
|
+
return CommandResult(
|
|
224
|
+
True,
|
|
225
|
+
f"{msg} โ tanya apa saja, jawaban berkutipan sumber. "
|
|
226
|
+
"Ctrl+R panel sumber.",
|
|
227
|
+
"mode")
|
|
228
|
+
if cmd == "/personal":
|
|
229
|
+
msg = self.set_mode(MODE_PERSONAL)
|
|
230
|
+
try:
|
|
231
|
+
from tools.personal.daemon_status import daemon_status
|
|
232
|
+
snapshot = daemon_status()["result"]
|
|
233
|
+
except Exception as e:
|
|
234
|
+
snapshot = f"(status daemon gagal dibaca: {e})"
|
|
235
|
+
return CommandResult(
|
|
236
|
+
True,
|
|
237
|
+
f"{msg} ๐ โ kelola bot, jadwal, briefing, daemon.\n"
|
|
238
|
+
f"{snapshot}",
|
|
239
|
+
"mode")
|
|
240
|
+
if cmd == "/clear":
|
|
241
|
+
return CommandResult(True, "๐งน History dibersihkan โ mulai sesi baru.", "clear")
|
|
242
|
+
if cmd == "/scan":
|
|
243
|
+
if self._rescan_fn is None:
|
|
244
|
+
return CommandResult(True, "๐ Scan ulang belum wiring (jalan di TUI).", "scan")
|
|
245
|
+
try:
|
|
246
|
+
return CommandResult(True, self._rescan_fn(), "scan")
|
|
247
|
+
except Exception as e:
|
|
248
|
+
return CommandResult(True, f"โ Scan gagal ({type(e).__name__}): {e}", None)
|
|
249
|
+
if cmd == "/model":
|
|
250
|
+
if not arg:
|
|
251
|
+
current = self._config.model if self._config else "?"
|
|
252
|
+
return CommandResult(True, f"๐ค Model aktif: {current}", None)
|
|
253
|
+
if self._config is None:
|
|
254
|
+
return CommandResult(True, "โ Config tidak tersedia โ model tidak bisa diganti.", None)
|
|
255
|
+
if not arg.strip():
|
|
256
|
+
return CommandResult(True, "โ Nama model tidak boleh kosong.", None)
|
|
257
|
+
self._config.model = arg.strip()
|
|
258
|
+
return CommandResult(True, f"โ
Model diganti ke: {self._config.model}", "model")
|
|
259
|
+
if cmd == "/help":
|
|
260
|
+
return CommandResult(True, HELP_TEXT, None)
|
|
261
|
+
if cmd == "/soul":
|
|
262
|
+
return CommandResult(True, self._soul or "(soul kosong)", None)
|
|
263
|
+
return CommandResult(True, f"โ Command tidak dikenal: {parts[0]}. Ketik /help.", None)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
from core.config import Config as _Config
|
|
268
|
+
|
|
269
|
+
cfg = _Config(model="m", api_key="k")
|
|
270
|
+
mm = ModeManager(config=cfg, soul="soul-test")
|
|
271
|
+
|
|
272
|
+
# 1. Default mode code + prompt-nya
|
|
273
|
+
assert mm.get_mode() == "code"
|
|
274
|
+
assert "Coding Agent" in mm.get_mode_prompt()
|
|
275
|
+
assert "conventional" in mm.get_mode_prompt() # smart commit flow
|
|
276
|
+
assert mm.get_active_tools() is None # semua tool aktif
|
|
277
|
+
mm.set_git_enabled(False)
|
|
278
|
+
active = mm.get_active_tools()
|
|
279
|
+
assert active is not None and not any(t.startswith("git_") for t in active), active
|
|
280
|
+
assert "read_file" in active and "bash" in active
|
|
281
|
+
mm.set_git_enabled(True)
|
|
282
|
+
assert mm.get_active_tools() is None
|
|
283
|
+
|
|
284
|
+
# 2. Bukan command โ teruskan ke LLM
|
|
285
|
+
r = mm.handle_command("halo, apa kabar?")
|
|
286
|
+
assert r.is_command is False, r
|
|
287
|
+
|
|
288
|
+
# 3. /code (case-insensitive) + /help + /soul
|
|
289
|
+
assert mm.handle_command("/CODE").message == "โ
Mode: code"
|
|
290
|
+
assert "/scan" in mm.handle_command("/help").message
|
|
291
|
+
assert mm.handle_command("/soul").message == "soul-test"
|
|
292
|
+
|
|
293
|
+
# 4. /research aktif (Phase 3); /personal aktif (Phase 4)
|
|
294
|
+
r = mm.handle_command("/research")
|
|
295
|
+
assert mm.get_mode() == "research" and r.action == "mode", (r, mm.get_mode())
|
|
296
|
+
assert "quick_research" in mm.get_mode_prompt()
|
|
297
|
+
active = mm.get_active_tools()
|
|
298
|
+
assert active is not None and "quick_research" in active, active
|
|
299
|
+
assert "deep_research" in active and "web_search" in active
|
|
300
|
+
assert "read_file" in active and "web_fetch" in active
|
|
301
|
+
assert "git_commit" not in active and "bash" not in active
|
|
302
|
+
mm.handle_command("/code")
|
|
303
|
+
assert mm.get_mode() == "code" and mm.get_active_tools() is None
|
|
304
|
+
r = mm.handle_command("/personal")
|
|
305
|
+
assert mm.get_mode() == "personal" and r.action == "mode", (r, mm.get_mode())
|
|
306
|
+
assert "Daemon:" in r.message, r.message # snapshot status ikut
|
|
307
|
+
assert "Personal Agent" in mm.get_mode_prompt()
|
|
308
|
+
active = mm.get_active_tools()
|
|
309
|
+
assert active is not None and "daemon_status" in active, active
|
|
310
|
+
assert "user_manager" in active and "generate_briefing" in active
|
|
311
|
+
assert "get_jobs" in active and "send_telegram" in active
|
|
312
|
+
assert "bash" not in active and "write_file" not in active # aman
|
|
313
|
+
assert "delete_file" not in active and "run_python" not in active
|
|
314
|
+
mm.handle_command("/code")
|
|
315
|
+
assert mm.get_mode() == "code"
|
|
316
|
+
|
|
317
|
+
# 5. /clear dan /scan kasih action signal
|
|
318
|
+
assert mm.handle_command("/clear").action == "clear"
|
|
319
|
+
assert mm.handle_command("/scan").action == "scan"
|
|
320
|
+
|
|
321
|
+
# 6. /model lihat + ganti
|
|
322
|
+
assert "m" in mm.handle_command("/model").message
|
|
323
|
+
r = mm.handle_command("/model openai/gpt-4o")
|
|
324
|
+
assert cfg.model == "openai/gpt-4o" and r.action == "model", (r, cfg.model)
|
|
325
|
+
|
|
326
|
+
# 7. Unknown command + "/" kosong tidak crash
|
|
327
|
+
assert "tidak dikenal" in mm.handle_command("/ngawur").message
|
|
328
|
+
assert "tidak dikenal" in mm.handle_command("/").message
|
|
329
|
+
|
|
330
|
+
# 8. Tanpa config: /model X ditolak dengan sopan
|
|
331
|
+
mm2 = ModeManager()
|
|
332
|
+
assert "tidak tersedia" in mm2.handle_command("/model x").message
|
|
333
|
+
|
|
334
|
+
# 9. /scan tanpa callback โ sinyal; dengan callback โ pesan display
|
|
335
|
+
assert mm2.handle_command("/scan").action == "scan"
|
|
336
|
+
mm3 = ModeManager(rescan_fn=lambda: "๐ demo ยท Python โ context diperbarui.")
|
|
337
|
+
r = mm3.handle_command("/scan")
|
|
338
|
+
assert r.action == "scan" and "demo" in r.message, r
|
|
339
|
+
mm4 = ModeManager(rescan_fn=lambda: (_ for _ in ()).throw(RuntimeError("disk")))
|
|
340
|
+
assert "gagal" in mm4.handle_command("/scan").message.lower()
|
|
341
|
+
|
|
342
|
+
print("โ
mode_manager self-test OK (9 skenario + research aktif)")
|