multacd 2.0.0b6__tar.gz → 2.0.0b8__tar.gz

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 (209) hide show
  1. {multacd-2.0.0b6 → multacd-2.0.0b8}/.github/workflows/release.yml +1 -1
  2. {multacd-2.0.0b6 → multacd-2.0.0b8}/CHANGELOG.md +18 -0
  3. {multacd-2.0.0b6 → multacd-2.0.0b8}/PKG-INFO +1 -5
  4. multacd-2.0.0b8/core/agent_events.py +86 -0
  5. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/agent_loop.py +140 -46
  6. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/config.py +83 -6
  7. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/llm_client.py +9 -2
  8. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/mode_manager.py +159 -5
  9. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/providers/__init__.py +13 -0
  10. multacd-2.0.0b8/core/research/bus.py +66 -0
  11. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/research/orchestrator.py +28 -11
  12. multacd-2.0.0b8/core/session_state.py +127 -0
  13. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/version.py +1 -1
  14. {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/install.md +2 -2
  15. {multacd-2.0.0b6 → multacd-2.0.0b8}/pyproject.toml +1 -11
  16. multacd-2.0.0b8/requirements.txt +11 -0
  17. {multacd-2.0.0b6 → multacd-2.0.0b8}/scripts/install.sh +1 -1
  18. multacd-2.0.0b8/search_providers/__init__.py +78 -0
  19. multacd-2.0.0b8/search_providers/exa.py +103 -0
  20. {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/tavily.py +29 -10
  21. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/integration/test_agent_loop.py +38 -7
  22. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/test_selftests.py +5 -0
  23. multacd-2.0.0b8/tests/unit/test_agent_controller.py +118 -0
  24. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_config.py +31 -0
  25. multacd-2.0.0b8/tests/unit/test_provider_keys.py +142 -0
  26. multacd-2.0.0b8/tests/unit/test_research_bus.py +69 -0
  27. multacd-2.0.0b8/tests/unit/test_search_providers/test_fallback.py +81 -0
  28. multacd-2.0.0b8/tests/unit/test_session_state.py +66 -0
  29. multacd-2.0.0b8/tests/unit/test_splash.py +20 -0
  30. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/quick_research.py +9 -2
  31. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/web_search.py +16 -21
  32. multacd-2.0.0b8/tui/controllers/agent_controller.py +184 -0
  33. multacd-2.0.0b8/tui/markup_safe.py +65 -0
  34. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/screens/main_screen.py +241 -59
  35. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/screens/setup_wizard.py +13 -2
  36. multacd-2.0.0b8/tui/widgets/__init__.py +0 -0
  37. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/chat_panel.py +77 -20
  38. multacd-2.0.0b8/tui/widgets/confirm_dialog.py +113 -0
  39. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/info_panel.py +2 -1
  40. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/model_selector.py +75 -5
  41. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/permission_popup.py +15 -7
  42. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/slash_palette.py +10 -4
  43. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/sources_panel.py +5 -2
  44. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/status_bar.py +31 -0
  45. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/thinking_bar.py +46 -1
  46. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/tool_activity.py +43 -20
  47. multacd-2.0.0b6/core/research/bus.py +0 -41
  48. multacd-2.0.0b6/requirements.txt +0 -12
  49. multacd-2.0.0b6/search_providers/__init__.py +0 -41
  50. multacd-2.0.0b6/search_providers/exa.py +0 -99
  51. multacd-2.0.0b6/tui/widgets/confirm_dialog.py +0 -56
  52. {multacd-2.0.0b6 → multacd-2.0.0b8}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  53. {multacd-2.0.0b6 → multacd-2.0.0b8}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  54. {multacd-2.0.0b6 → multacd-2.0.0b8}/.github/pull_request_template.md +0 -0
  55. {multacd-2.0.0b6 → multacd-2.0.0b8}/.github/workflows/test.yml +0 -0
  56. {multacd-2.0.0b6 → multacd-2.0.0b8}/.gitignore +0 -0
  57. {multacd-2.0.0b6 → multacd-2.0.0b8}/CONTRIBUTING.md +0 -0
  58. {multacd-2.0.0b6 → multacd-2.0.0b8}/DESIGN.md +0 -0
  59. {multacd-2.0.0b6 → multacd-2.0.0b8}/LICENSE +0 -0
  60. {multacd-2.0.0b6 → multacd-2.0.0b8}/README.md +0 -0
  61. {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/__init__.py +0 -0
  62. {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/generator.py +0 -0
  63. {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/privacy.py +0 -0
  64. {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/sources/__init__.py +0 -0
  65. {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/sources/git_source.py +0 -0
  66. {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/sources/news_source.py +0 -0
  67. {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/sources/todo_source.py +0 -0
  68. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/__init__.py +0 -0
  69. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/clipboard.py +0 -0
  70. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/codebase.py +0 -0
  71. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/permissions.py +0 -0
  72. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/plugin_loader.py +0 -0
  73. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/prompt_composer.py +0 -0
  74. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/providers/__main__.py +0 -0
  75. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/providers/anthropic.py +0 -0
  76. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/providers/openai_compat.py +0 -0
  77. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/research/__init__.py +0 -0
  78. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/research/query_generator.py +0 -0
  79. {multacd-2.0.0b6 → multacd-2.0.0b8}/core/updater.py +0 -0
  80. {multacd-2.0.0b6 → multacd-2.0.0b8}/daemon/__init__.py +0 -0
  81. {multacd-2.0.0b6 → multacd-2.0.0b8}/daemon/ipc.py +0 -0
  82. {multacd-2.0.0b6 → multacd-2.0.0b8}/daemon/process.py +0 -0
  83. {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/configuration.md +0 -0
  84. {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/index.md +0 -0
  85. {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/mkdocs.yml +0 -0
  86. {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/modes/coding.md +0 -0
  87. {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/modes/personal.md +0 -0
  88. {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/modes/research.md +0 -0
  89. {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/plugins.md +0 -0
  90. {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/quickstart.md +0 -0
  91. {multacd-2.0.0b6 → multacd-2.0.0b8}/main.py +0 -0
  92. {multacd-2.0.0b6 → multacd-2.0.0b8}/memory/__init__.py +0 -0
  93. {multacd-2.0.0b6 → multacd-2.0.0b8}/memory/context.py +0 -0
  94. {multacd-2.0.0b6 → multacd-2.0.0b8}/memory/store.py +0 -0
  95. {multacd-2.0.0b6 → multacd-2.0.0b8}/multacd.spec +0 -0
  96. {multacd-2.0.0b6 → multacd-2.0.0b8}/plan/PLAN-phase1.md +0 -0
  97. {multacd-2.0.0b6 → multacd-2.0.0b8}/plan/PLAN-phase2.md +0 -0
  98. {multacd-2.0.0b6 → multacd-2.0.0b8}/plugins/README.md +0 -0
  99. {multacd-2.0.0b6 → multacd-2.0.0b8}/plugins/example_plugin.py +0 -0
  100. {multacd-2.0.0b6 → multacd-2.0.0b8}/roadmap/ROADMAP.md +0 -0
  101. {multacd-2.0.0b6 → multacd-2.0.0b8}/scheduler/__init__.py +0 -0
  102. {multacd-2.0.0b6 → multacd-2.0.0b8}/scheduler/engine.py +0 -0
  103. {multacd-2.0.0b6 → multacd-2.0.0b8}/scheduler/jobs/__init__.py +0 -0
  104. {multacd-2.0.0b6 → multacd-2.0.0b8}/scheduler/jobs/briefing_job.py +0 -0
  105. {multacd-2.0.0b6 → multacd-2.0.0b8}/scheduler/jobs/research_job.py +0 -0
  106. {multacd-2.0.0b6 → multacd-2.0.0b8}/scripts/install.ps1 +0 -0
  107. {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/__main__.py +0 -0
  108. {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/base.py +0 -0
  109. {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/brave.py +0 -0
  110. {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/duckduckgo.py +0 -0
  111. {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/serpapi.py +0 -0
  112. {multacd-2.0.0b6 → multacd-2.0.0b8}/soul.md +0 -0
  113. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/__init__.py +0 -0
  114. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/conftest.py +0 -0
  115. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/integration/__init__.py +0 -0
  116. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/__init__.py +0 -0
  117. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_binary.py +0 -0
  118. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_docs.py +0 -0
  119. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_entrypoint.py +0 -0
  120. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_github.py +0 -0
  121. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_installer.py +0 -0
  122. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_permissions.py +0 -0
  123. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_plugin_loader.py +0 -0
  124. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_privacy_filter.py +0 -0
  125. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_search_providers/__init__.py +0 -0
  126. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_search_providers/test_tavily.py +0 -0
  127. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_tools/__init__.py +0 -0
  128. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_tools/test_bash.py +0 -0
  129. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_tools/test_read_file.py +0 -0
  130. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_tools/test_web_scrape.py +0 -0
  131. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_tools/test_write_file.py +0 -0
  132. {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_updater.py +0 -0
  133. {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/__init__.py +0 -0
  134. {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/access_control.py +0 -0
  135. {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/agent.py +0 -0
  136. {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/bot.py +0 -0
  137. {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/file_handler.py +0 -0
  138. {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/formatter.py +0 -0
  139. {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/handlers.py +0 -0
  140. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/__init__.py +0 -0
  141. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/agent/__init__.py +0 -0
  142. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/agent/ask.py +0 -0
  143. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/agent/skill.py +0 -0
  144. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/agent/task.py +0 -0
  145. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/agent/todo_write.py +0 -0
  146. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/code/__init__.py +0 -0
  147. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/code/lint_python.py +0 -0
  148. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/code/run_python.py +0 -0
  149. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/code/run_tests.py +0 -0
  150. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/codebase/__init__.py +0 -0
  151. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/codebase/scan_codebase.py +0 -0
  152. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/common.py +0 -0
  153. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/__init__.py +0 -0
  154. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/apply_patch.py +0 -0
  155. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/delete_file.py +0 -0
  156. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/edit_file.py +0 -0
  157. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/glob.py +0 -0
  158. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/grep.py +0 -0
  159. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/list_dir.py +0 -0
  160. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/move_file.py +0 -0
  161. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/multi_edit.py +0 -0
  162. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/read_file.py +0 -0
  163. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/read_many_files.py +0 -0
  164. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/write_file.py +0 -0
  165. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/__init__.py +0 -0
  166. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/_helper.py +0 -0
  167. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_add.py +0 -0
  168. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_branch.py +0 -0
  169. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_checkout.py +0 -0
  170. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_commit.py +0 -0
  171. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_diff.py +0 -0
  172. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_log.py +0 -0
  173. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_merge.py +0 -0
  174. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_pull.py +0 -0
  175. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_push.py +0 -0
  176. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_status.py +0 -0
  177. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/memory/__init__.py +0 -0
  178. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/memory/forget.py +0 -0
  179. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/memory/recall.py +0 -0
  180. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/memory/remember.py +0 -0
  181. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/__init__.py +0 -0
  182. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/cancel_job.py +0 -0
  183. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/daemon_status.py +0 -0
  184. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/generate_briefing.py +0 -0
  185. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/get_jobs.py +0 -0
  186. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/schedule_job.py +0 -0
  187. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/send_telegram.py +0 -0
  188. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/user_manager.py +0 -0
  189. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/registry.py +0 -0
  190. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/__init__.py +0 -0
  191. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/deep_research.py +0 -0
  192. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/export_research.py +0 -0
  193. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/web_scrape.py +0 -0
  194. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/shell/__init__.py +0 -0
  195. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/shell/bash.py +0 -0
  196. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/streaming.py +0 -0
  197. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/web/__init__.py +0 -0
  198. {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/web/web_fetch.py +0 -0
  199. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/__init__.py +0 -0
  200. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/app.py +0 -0
  201. {multacd-2.0.0b6/tui/screens → multacd-2.0.0b8/tui/controllers}/__init__.py +0 -0
  202. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/icons.py +0 -0
  203. {multacd-2.0.0b6/tui/widgets → multacd-2.0.0b8/tui/screens}/__init__.py +0 -0
  204. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/themes/__init__.py +0 -0
  205. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/themes/__main__.py +0 -0
  206. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/tokens.py +0 -0
  207. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/diff_viewer.py +0 -0
  208. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/file_tree.py +0 -0
  209. {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/input_bar.py +0 -0
@@ -101,7 +101,7 @@ jobs:
101
101
 
102
102
  ```bash
103
103
  pkg install -y python git
104
- pip install "multacd==2.0.0b6"
104
+ pip install "multacd==2.0.0b8"
105
105
  ```
106
106
 
107
107
  Windows (PowerShell):
@@ -2,6 +2,24 @@
2
2
 
3
3
  Format: Keep a Changelog. Versi: Semantic Versioning.
4
4
 
5
+ ## [2.0.0b8] - 2026-09-13
6
+
7
+ Beta v2 kedelapan (refactor arsitektur, tanpa ubah fitur): kontrak event
8
+ `core/agent_events` (R2), SessionState + reducer keluar widget (R3),
9
+ AgentController + fix approve [A] lintas turn (#32, R4), widget render
10
+ dari state (R5), research sink task-scoped (R6), splash §10 lengkap (R7).
11
+
12
+ Install: `pip install "multacd==2.0.0b8"` (tanpa --pre).
13
+
14
+ ## [2.0.0b7] - 2026-09-12
15
+
16
+ Beta v2 ketujuh (borongan): config key kosong dimaafkan (#27), limit jadi
17
+ tawaran Lanjut/Berhenti + stagnan outcome-aware (#28), markup crash (#29),
18
+ tavily+exa native tanpa SDK + fallback operasional (#30), key per-provider
19
+ + /key /base (#31), splash cover + /connect + /models.
20
+
21
+ Install: `pip install "multacd==2.0.0b7"` (tanpa --pre).
22
+
5
23
  ## [2.0.0b6] - 2026-09-12
6
24
 
7
25
  Beta v2 keenam — fix mode lupa ingatan (#26): system message di-refresh
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: multacd
3
- Version: 2.0.0b6
3
+ Version: 2.0.0b8
4
4
  Summary: Agentic TUI for coding, research, and personal automation
5
5
  Project-URL: Homepage, https://github.com/prototypeall850-creator/multacd
6
6
  Project-URL: Issues, https://github.com/prototypeall850-creator/multacd/issues
@@ -29,10 +29,6 @@ Requires-Dist: pytest-asyncio; extra == 'dev'
29
29
  Requires-Dist: pytest-cov; extra == 'dev'
30
30
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
31
31
  Requires-Dist: ruff>=0.4.0; extra == 'dev'
32
- Provides-Extra: exa
33
- Requires-Dist: exa-py>=1.0.0; extra == 'exa'
34
- Provides-Extra: tavily
35
- Requires-Dist: tavily-python>=0.3.0; extra == 'tavily'
36
32
  Description-Content-Type: text/markdown
37
33
 
38
34
  # ⚡ multacd
@@ -0,0 +1,86 @@
1
+ """Agent event contract — satu-satunya bahasa agent runtime → UI.
2
+
3
+ Dipakai `core/agent_loop.py` (yield) dan consumer (`tui`, `tg`, tests).
4
+ Modul ini murni dataclass: tanpa import Textual, tanpa I/O, tanpa LLM —
5
+ tujuannya biar boundary tidak gampang bocor saat refactor (R2).
6
+
7
+ Test cepat:
8
+ python -m core.agent_events
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+
17
+ @dataclass
18
+ class AgentText:
19
+ delta: str
20
+
21
+
22
+ @dataclass
23
+ class AgentToolStart:
24
+ call_id: str
25
+ name: str
26
+ params: dict[str, Any]
27
+
28
+
29
+ @dataclass
30
+ class AgentToolDone:
31
+ call_id: str
32
+ name: str
33
+ success: bool
34
+ result: dict[str, Any] | None = None # payload penuh (D4: expandable view)
35
+
36
+
37
+ @dataclass
38
+ class AgentDone:
39
+ text: str
40
+
41
+
42
+ @dataclass
43
+ class AgentUsage:
44
+ """Token resmi provider untuk satu panggilan LLM (0 = tak dilapor)."""
45
+
46
+ prompt_tokens: int = 0
47
+ completion_tokens: int = 0
48
+ cost_usd: float = 0.0 # estimasi lokal (tabel harga provider)
49
+
50
+
51
+ @dataclass
52
+ class AgentContinue:
53
+ """LLM minta lanjut padahal batas iterasi tercapai.
54
+
55
+ Bukan error — TUI tampilkan ringkasan + tombol Lanjut/Berhenti.
56
+ CLI fallback: auto-lanjut kalau stdin bukan TTY? tidak — default berhenti.
57
+ Lihat run_agent(continue_on_limit) + main_screen._run_turn.
58
+ """
59
+
60
+ tool_count: int
61
+ limit: int
62
+ summary: str # ringkasan tool terakhir biar user bisa nilai
63
+
64
+
65
+ @dataclass
66
+ class AgentError:
67
+ message: str
68
+
69
+
70
+ AgentEvent = AgentText | AgentToolStart | AgentToolDone | AgentDone | AgentUsage | AgentContinue | AgentError
71
+
72
+
73
+ if __name__ == "__main__":
74
+ evs: list[AgentEvent] = [
75
+ AgentText("halo"),
76
+ AgentToolStart("c1", "list_dir", {"path": "."}),
77
+ AgentToolDone("c1", "list_dir", True, {"success": True}),
78
+ AgentDone("selesai"),
79
+ AgentUsage(10, 5, 0.001),
80
+ AgentContinue(3, 3, "list_dir"),
81
+ AgentError("gagal"),
82
+ ]
83
+ assert len(evs) == 7
84
+ assert isinstance(evs[0], AgentText) and evs[0].delta == "halo"
85
+ assert isinstance(evs[5], AgentContinue) and evs[5].limit == 3
86
+ print("✅ agent_events self-test OK (7 event contract)")
@@ -22,9 +22,18 @@ from __future__ import annotations
22
22
 
23
23
  import asyncio
24
24
  from collections.abc import AsyncIterator, Awaitable, Callable
25
- from dataclasses import dataclass
26
25
  from typing import TYPE_CHECKING, Any
27
26
 
27
+ from core.agent_events import (
28
+ AgentContinue,
29
+ AgentDone,
30
+ AgentError,
31
+ AgentEvent,
32
+ AgentText,
33
+ AgentToolDone,
34
+ AgentToolStart,
35
+ AgentUsage,
36
+ )
28
37
  from core.config import Config
29
38
  from core.llm_client import LLMClient, LLMError, StreamDone, StreamText, setup_client
30
39
  from core.permissions import PermissionChecker
@@ -54,46 +63,28 @@ AskCallback = Callable[[str], Awaitable[str]]
54
63
  OutputCallback = Callable[[str, str], None]
55
64
 
56
65
 
57
- @dataclass
58
- class AgentText:
59
- delta: str
60
-
61
-
62
- @dataclass
63
- class AgentToolStart:
64
- call_id: str
65
- name: str
66
- params: dict[str, Any]
67
-
68
-
69
- @dataclass
70
- class AgentToolDone:
71
- call_id: str
72
- name: str
73
- success: bool
74
- result: dict[str, Any] | None = None # payload penuh (D4: expandable view)
75
-
76
-
77
- @dataclass
78
- class AgentDone:
79
- text: str
80
-
81
-
82
- @dataclass
83
- class AgentUsage:
84
- """Token resmi provider untuk satu panggilan LLM (0 = tak dilapor)."""
85
-
86
- prompt_tokens: int = 0
87
- completion_tokens: int = 0
88
- cost_usd: float = 0.0 # estimasi lokal (tabel harga provider)
89
-
90
-
91
- @dataclass
92
- class AgentError:
93
- message: str
66
+ # Re-export biar import lama (`from core.agent_loop import AgentText, ...`)
67
+ # tetap jalan selama migrasi (R2: tanpa ubah behavior).
68
+ __all__ = [
69
+ "AgentContinue",
70
+ "AgentDone",
71
+ "AgentError",
72
+ "AgentEvent",
73
+ "AgentText",
74
+ "AgentToolDone",
75
+ "AgentToolStart",
76
+ "AgentUsage",
77
+ "run_agent",
78
+ ]
94
79
 
95
80
 
96
- AgentEvent = AgentText | AgentToolStart | AgentToolDone | AgentDone | AgentUsage | AgentError
81
+ def _stuck_error(tool_name: str) -> AgentError:
82
+ """Pesan stagnan: tool+args sama gagal error-sama 3x beruntun."""
83
+ return AgentError(
84
+ f"`{tool_name}` gagal dengan error yang sama 3x beruntun — "
85
+ "kemungkinan stuck. Coba pecah tugas jadi langkah kecil "
86
+ "atau kasih instruksi lebih spesifik."
87
+ )
97
88
 
98
89
 
99
90
  async def _stdin_confirm(tool_name: str, params: dict[str, Any]) -> str:
@@ -143,8 +134,23 @@ async def run_agent(
143
134
  active_tools: list[str] | None = None,
144
135
  composer: PromptComposer | None = None,
145
136
  output_cb: OutputCallback | None = None,
137
+ continue_on_limit: bool = False,
138
+ checker: PermissionChecker | None = None,
146
139
  ) -> AsyncIterator[AgentEvent]:
147
- """Jalankan satu turn agent. Yield AgentEvent secara real-time."""
140
+ """Jalankan satu turn agent. Yield AgentEvent secara real-time.
141
+
142
+ Batas iterasi (ala opencode): tiap LLM round yang berisi tool call
143
+ = 1 step. Capai limit → yield AgentContinue (bukan AgentError).
144
+ TUI tanya user Lanjut/Berhenti; continue_on_limit=True (dipakai
145
+ retry lanjutan) langsung lanjut tanpa tanya. Infinite loop nyata
146
+ (LLM ngulang tool sama tanpa progres) tetap berhenti via deteksi
147
+ stagnan di bawah.
148
+
149
+ `checker` opsional biar pemilik sesi (AgentController) bisa pakai
150
+ satu PermissionChecker lintas turn — approve [A] jadi beneran
151
+ per-sesi, bukan hilang tiap turn (issue #32). Default None =
152
+ checker baru per turn (perilaku lama, dipakai CLI/test).
153
+ """
148
154
  def _system_prompt() -> str:
149
155
  return composer.compose() if composer is not None else SYSTEM_PROMPT
150
156
 
@@ -159,7 +165,7 @@ async def run_agent(
159
165
  return
160
166
 
161
167
  llm = llm_client or setup_client(config)
162
- checker = PermissionChecker(config)
168
+ checker = checker or PermissionChecker(config)
163
169
  confirm_cb = confirm or _stdin_confirm
164
170
  ask_cb = ask_user or _stdin_ask
165
171
  defs = get_tool_definitions()
@@ -182,12 +188,48 @@ async def run_agent(
182
188
  context.add_message("user", user_input)
183
189
 
184
190
  iteration = 0
191
+ tool_rounds = 0 # round LLM yang berisi tool call (step ala opencode)
192
+ recent_names: list[str] = [] # nama tool terakhir (ringkasan Continue)
193
+ recent_fails: list[tuple[str, str, str]] = [] # (nama, args, err) stagnan
194
+
195
+ def _track_outcome(name: str, args: dict[str, Any],
196
+ res: dict[str, Any]) -> bool:
197
+ """Catat hasil tool. True = stagnan: tool+args yang sama gagal
198
+ dengan error yang sama 3x beruntun (tanpa progres). Sukses atau
199
+ error yang beda = progres, tidak dihitung. Issue #28."""
200
+ import json as _js
201
+ try:
202
+ sig = _js.dumps(args, sort_keys=True, ensure_ascii=False,
203
+ default=str)
204
+ except Exception:
205
+ sig = ""
206
+ err = str(res.get("error") or "").splitlines()
207
+ recent_fails.append((name, sig, err[0][:120] if err else ""))
208
+ if len(recent_fails) < 3:
209
+ return False
210
+ a, b, c = recent_fails[-3:]
211
+ return (a[0] == b[0] == c[0] and a[1] == b[1] == c[1]
212
+ and bool(a[2]) and a[2] == b[2] == c[2])
185
213
  while True:
186
214
  iteration += 1
187
- if iteration > config.max_tool_iterations:
215
+ if iteration > config.max_tool_iterations and not continue_on_limit:
216
+ # Limit = proteksi, bukan vonis. Kasih user pilihan lanjut —
217
+ # opencode juga begini (lanjut tanpa reset konteks).
218
+ summary = ", ".join(recent_names[-3:]) or "(belum ada tool)"
219
+ yield AgentContinue(
220
+ tool_count=tool_rounds,
221
+ limit=config.max_tool_iterations,
222
+ summary=f"Batas {config.max_tool_iterations} iterasi tercapai "
223
+ f"({tool_rounds} tool call: {summary}). "
224
+ "Pilih Lanjut buat terusin, Berhenti buat sudahi.",
225
+ )
226
+ return
227
+ if iteration > config.max_tool_iterations * 3:
228
+ # Safety net absolut: 3x limit tanpa selesai = loop beneran.
188
229
  yield AgentError(
189
- f"Mencapai batas {config.max_tool_iterations} iterasi tool. "
190
- "Berhenti agar tidak infinite loop — coba pecah tugas jadi langkah kecil."
230
+ f"Berhenti setelah {config.max_tool_iterations * 3} iterasi "
231
+ "tanpa selesai — kemungkinan infinite loop. "
232
+ "Coba pecah tugas jadi langkah kecil."
191
233
  )
192
234
  return
193
235
 
@@ -218,9 +260,12 @@ async def run_agent(
218
260
  yield AgentDone(done.text)
219
261
  return
220
262
 
263
+ tool_rounds += 1
264
+
221
265
  # ── Act + Observe: proses tiap tool call ──
222
266
  for call in done.tool_calls:
223
267
  yield AgentToolStart(call.id, call.name, call.arguments)
268
+ recent_names.append(call.name)
224
269
 
225
270
  # Tool "ask" dicegat: jawab via callback, bukan execute_tool (stdin).
226
271
  if call.name == "ask":
@@ -229,6 +274,7 @@ async def run_agent(
229
274
  ask_result = {"success": True, "result": answer, "error": None}
230
275
  context.add_tool_result(call.id, "ask", ask_result)
231
276
  yield AgentToolDone(call.id, "ask", True, ask_result)
277
+ _track_outcome("ask", call.arguments, ask_result)
232
278
  continue
233
279
 
234
280
  decision = checker.check(call.name, call.arguments)
@@ -237,6 +283,9 @@ async def run_agent(
237
283
  f"Tool `{call.name}` tidak tersedia. Pakai tool dari daftar yang ada.")
238
284
  context.add_tool_result(call.id, call.name, deny_result)
239
285
  yield AgentToolDone(call.id, call.name, False, deny_result)
286
+ if _track_outcome(call.name, call.arguments, deny_result):
287
+ yield _stuck_error(call.name)
288
+ return
240
289
  continue
241
290
 
242
291
  if decision == "ask":
@@ -270,6 +319,9 @@ async def run_agent(
270
319
  if not made.get("success"):
271
320
  context.add_tool_result(call.id, call.name, made)
272
321
  yield AgentToolDone(call.id, call.name, False, made)
322
+ if _track_outcome(call.name, call.arguments, made):
323
+ yield _stuck_error(call.name)
324
+ return
273
325
  continue
274
326
  call.arguments["branch"] = new_branch
275
327
  call.arguments.pop("allow_protected", None)
@@ -280,6 +332,9 @@ async def run_agent(
280
332
  "cari cara lain atau tanya user.")
281
333
  context.add_tool_result(call.id, call.name, cancel_result)
282
334
  yield AgentToolDone(call.id, call.name, False, cancel_result)
335
+ if _track_outcome(call.name, call.arguments, cancel_result):
336
+ yield _stuck_error(call.name)
337
+ return
283
338
  continue
284
339
 
285
340
  # to_thread: tool sync jalan di thread, event loop tetap hidup.
@@ -294,6 +349,9 @@ async def run_agent(
294
349
  _live if output_cb is not None else None)
295
350
  context.add_tool_result(call.id, call.name, result)
296
351
  yield AgentToolDone(call.id, call.name, result["success"], result)
352
+ if _track_outcome(call.name, call.arguments, result):
353
+ yield _stuck_error(call.name)
354
+ return
297
355
 
298
356
 
299
357
  if __name__ == "__main__":
@@ -389,13 +447,49 @@ if __name__ == "__main__":
389
447
  assert "tidak tersedia" in ctx.get_messages()[-2]["content"]
390
448
  assert isinstance(events[-1], AgentDone)
391
449
 
392
- # 6. Infinite loop → berhenti di batas
450
+ # 6. Limit → AgentContinue (bukan error): user bisa lanjut.
451
+ # continue_on_limit=True = lanjutan tanpa tanya (dipakai TUI).
452
+ # NOTE: jangan import ulang core.agent_loop di sini (double-import
453
+ # trap python -m: kelas ganda, isinstance gagal). Pakai nama lokal.
393
454
  cfg2 = Config(model="m", api_key="k", max_tool_iterations=3)
394
455
  fake = FakeLLM([StreamDone("", [_TCR("c9", "list_dir", {})])] * 10)
395
456
  ctx = ConversationContext()
396
457
  events = await _drain(run_agent("loop", ctx, cfg2, llm_client=fake,
397
458
  confirm=_no_confirm))
398
- assert isinstance(events[-1], AgentError) and "3 iterasi" in events[-1].message
459
+ assert isinstance(events[-1], AgentContinue), events[-1]
460
+ assert events[-1].limit == 3 and events[-1].tool_count == 3
461
+ assert "list_dir" in events[-1].summary
462
+ # Lanjutan: konteks utuh, loop terus sampai LLM selesai.
463
+ # 5 tool (< 3x limit=9) lalu selesai → AgentDone, bukan safety net.
464
+ fake = FakeLLM([StreamDone("", [_TCR("c9", "list_dir", {})])] * 5
465
+ + [StreamDone("akhirnya selesai", [])])
466
+ ctx = ConversationContext()
467
+ events = await _drain(run_agent("loop", ctx, cfg2, llm_client=fake,
468
+ confirm=_no_confirm,
469
+ continue_on_limit=True))
470
+ assert isinstance(events[-1], AgentDone), events[-1]
471
+ assert "selesai" in events[-1].text
472
+ # Safety net absolut: 3x limit tanpa selesai = error beneran.
473
+ # Pakai call SUKSES berulang (stagnan cuma bunuh gagal-sama-3x).
474
+ fake = FakeLLM([StreamDone("", [_TCR("c9", "list_dir", {})])] * 100)
475
+ ctx = ConversationContext()
476
+ events = await _drain(run_agent("loop", ctx, cfg2, llm_client=fake,
477
+ confirm=_no_confirm,
478
+ continue_on_limit=True))
479
+ assert isinstance(events[-1], AgentError), events[-1]
480
+ assert "9 iterasi" in events[-1].message
481
+
482
+ # 6b. Stagnan: tool+args sama GAGAL error-sama 3x → setop.
483
+ # Sukses berulang = progres (retry), tidak dihitung.
484
+ cfg3 = Config(model="m", api_key="k", max_tool_iterations=20)
485
+ fake = FakeLLM([StreamDone("", [_TCR("c1", "read_file",
486
+ {"path": "a.py"})])] * 5)
487
+ ctx = ConversationContext()
488
+ events = await _drain(run_agent("stuck", ctx, cfg3, llm_client=fake,
489
+ confirm=_no_confirm))
490
+ assert isinstance(events[-1], AgentError), events[-1]
491
+ assert "3x" in events[-1].message and "read_file" in events[-1].message
492
+ # Sukses-sama 3x = bukan stagnan (dibuktikan skenario 6 lanjutan).
399
493
 
400
494
  # 7. Tool ask() dicegat via callback (tanpa stdin)
401
495
  async def _answer(q):
@@ -128,6 +128,12 @@ SCHEDULE_ACTIONS = ("briefing", "research")
128
128
 
129
129
 
130
130
  def _str(loc: str, v: object, errs: list[str], min_len: int = 0) -> str:
131
+ # YAML `key:` kosong → None. Field opsional (min_len=0) maafkan jadi "",
132
+ # field wajib tetap error tapi pesannya "wajib diisi", bukan "NoneType".
133
+ if v is None:
134
+ if min_len > 0:
135
+ errs.append(f"{loc}: wajib diisi (kosong)")
136
+ return ""
131
137
  if isinstance(v, bool) or not isinstance(v, (str, int, float)):
132
138
  errs.append(f"{loc}: harus teks, dapat {type(v).__name__}")
133
139
  return "" if isinstance(v, str) else str(v) if v is not None else ""
@@ -140,9 +146,9 @@ def _str(loc: str, v: object, errs: list[str], min_len: int = 0) -> str:
140
146
  def _int(loc: str, v: object, errs: list[str], gt: int | None = None,
141
147
  le: int | None = None) -> int:
142
148
  n: int | None = None
143
- if isinstance(v, bool):
144
- pass
145
- elif isinstance(v, int):
149
+ if v is None or (isinstance(v, str) and not v.strip()):
150
+ n = 0 # key kosong = 0; lolos kalau opsional, error gt/le kalau wajib
151
+ elif isinstance(v, int) and not isinstance(v, bool):
146
152
  n = v
147
153
  elif isinstance(v, str) and v.strip().lstrip("+-").isdigit():
148
154
  n = int(v.strip())
@@ -159,9 +165,9 @@ def _int(loc: str, v: object, errs: list[str], gt: int | None = None,
159
165
  def _float(loc: str, v: object, errs: list[str], ge: float | None = None,
160
166
  le: float | None = None) -> float:
161
167
  f: float | None = None
162
- if isinstance(v, bool):
163
- pass
164
- elif isinstance(v, (int, float)):
168
+ if v is None or (isinstance(v, str) and not v.strip()):
169
+ f = 0.0 # key kosong = 0.0; lolos kalau opsional, error ge/le kalau wajib
170
+ elif isinstance(v, (int, float)) and not isinstance(v, bool):
165
171
  f = float(v)
166
172
  elif isinstance(v, str):
167
173
  try:
@@ -179,6 +185,8 @@ def _float(loc: str, v: object, errs: list[str], ge: float | None = None,
179
185
 
180
186
 
181
187
  def _bool(loc: str, v: object, errs: list[str]) -> bool:
188
+ if v is None:
189
+ return False # key kosong = default False (opsional)
182
190
  if isinstance(v, bool):
183
191
  return v
184
192
  if isinstance(v, str) and v.strip().lower() in (
@@ -190,6 +198,8 @@ def _bool(loc: str, v: object, errs: list[str]) -> bool:
190
198
 
191
199
  def _choice(loc: str, v: object, errs: list[str], options: tuple[str, ...],
192
200
  normalize: bool = False) -> str:
201
+ if v is None:
202
+ return options[0] # key kosong = default opsi pertama
193
203
  s = v.lower().strip() if normalize and isinstance(v, str) else v
194
204
  if not isinstance(s, str) or s not in options:
195
205
  errs.append(f"{loc}: harus salah satu dari {', '.join(options)}, "
@@ -198,6 +208,24 @@ def _choice(loc: str, v: object, errs: list[str], options: tuple[str, ...],
198
208
  return s
199
209
 
200
210
 
211
+ def _strmap(loc: str, v: object, errs: list[str]) -> dict[str, str]:
212
+ """Dict str→str (provider_keys/bases). None → {}. Key dilower-case,
213
+ value kosong dibuang. Bukan mapping / value bukan str = error."""
214
+ if v is None:
215
+ return {}
216
+ if not isinstance(v, dict):
217
+ errs.append(f"{loc}: harus mapping, dapat {type(v).__name__}")
218
+ return {}
219
+ out: dict[str, str] = {}
220
+ for k, val in v.items():
221
+ if not isinstance(k, str) or not isinstance(val, str):
222
+ errs.append(f"{loc}.{k}: key dan value harus teks")
223
+ continue
224
+ if val.strip():
225
+ out[k.lower().strip()] = val
226
+ return out
227
+
228
+
201
229
  def _raise_if_errors(errs: list[str]) -> None:
202
230
  if errs:
203
231
  raise ConfigError("config tidak valid: " + "; ".join(errs), errs)
@@ -213,11 +241,14 @@ class TelegramConfig:
213
241
  allowed_users: list[int] = field(default_factory=list)
214
242
 
215
243
  def __post_init__(self) -> None:
244
+ # Normalisasi None sudah ditangani _str/_int (key kosong → default).
216
245
  errs: list[str] = []
217
246
  self.bot_token = _str("telegram.bot_token", self.bot_token, errs)
218
247
  self.admin_id = _int("telegram.admin_id", self.admin_id, errs)
219
248
  self.admin_username = _str("telegram.admin_username",
220
249
  self.admin_username, errs)
250
+ if self.allowed_users is None:
251
+ self.allowed_users = [] # `allowed_users:` kosong = []
221
252
  if not isinstance(self.allowed_users, list):
222
253
  errs.append(f"telegram.allowed_users: harus list, "
223
254
  f"dapat {self.allowed_users!r}")
@@ -270,6 +301,9 @@ class BriefingConfig:
270
301
  self.news = _bool("briefing.news", self.news, errs)
271
302
  self.git_status = _bool("briefing.git_status", self.git_status, errs)
272
303
  self.weather = _bool("briefing.weather", self.weather, errs)
304
+ if self.news_topics is None:
305
+ self.news_topics = ["artificial intelligence",
306
+ "software engineering"]
273
307
  if not isinstance(self.news_topics, list) or not all(
274
308
  isinstance(t, str) for t in self.news_topics):
275
309
  errs.append("briefing.news_topics: harus list of string")
@@ -283,6 +317,8 @@ def _nested(loc: str, cls: type, v: object, errs: list[str]) -> object:
283
317
  """Dict → dataclass nested. Error nested digabung ke errs caller."""
284
318
  if isinstance(v, cls):
285
319
  return v
320
+ if v is None:
321
+ return cls() # `telegram:` kosong tanpa isi = pakai default (nonaktif)
286
322
  if isinstance(v, dict):
287
323
  known = {f.name for f in fields(cls)}
288
324
  try:
@@ -302,6 +338,10 @@ class Config:
302
338
  model: str = ""
303
339
  api_key: str = ""
304
340
  api_base: str | None = None
341
+ # Key/base per provider (#31): {"openai": "sk-...", ...}. Dipakai saat
342
+ # model pindah provider; fallback = api_key/api_base top-level.
343
+ provider_keys: dict = field(default_factory=dict)
344
+ provider_bases: dict = field(default_factory=dict)
305
345
 
306
346
  # ── Agent settings ──
307
347
  max_tokens: int = 8096
@@ -345,6 +385,10 @@ class Config:
345
385
  self.api_key = _str("api_key", self.api_key, errs, min_len=1)
346
386
  if self.api_base is not None:
347
387
  self.api_base = _str("api_base", self.api_base, errs)
388
+ self.provider_keys = _strmap("provider_keys", self.provider_keys,
389
+ errs)
390
+ self.provider_bases = _strmap("provider_bases", self.provider_bases,
391
+ errs)
348
392
  self.max_tokens = _int("max_tokens", self.max_tokens, errs, gt=0)
349
393
  self.temperature = _float("temperature", self.temperature, errs,
350
394
  ge=0.0, le=2.0)
@@ -398,6 +442,8 @@ class Config:
398
442
  errs)
399
443
  self.telegram = _nested("telegram", TelegramConfig,
400
444
  self.telegram, errs)
445
+ if self.schedules is None:
446
+ self.schedules = [] # `schedules:` kosong = []
401
447
  if not isinstance(self.schedules, list):
402
448
  errs.append(f"schedules: harus list, "
403
449
  f"dapat {type(self.schedules).__name__}")
@@ -484,6 +530,37 @@ def config_from_dict(raw: dict) -> Config:
484
530
  return Config(**{k: v for k, v in raw.items() if k in known})
485
531
 
486
532
 
533
+ def save_config_updates(updates: dict,
534
+ explicit_path: Path | str | None = None) -> None:
535
+ """Merge updates ke config.yaml lalu tulis (buat /model /key /base).
536
+
537
+ Dict di-merge per-key (provider_keys lama dipertahankan), sisanya
538
+ replace. Raise OSError kalau gagal tulis — caller yang berpesan.
539
+ """
540
+ path = resolve_config_path(explicit_path)
541
+ try:
542
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
543
+ except (OSError, UnicodeDecodeError, yaml.YAMLError):
544
+ raw = {}
545
+ if not isinstance(raw, dict):
546
+ raw = {}
547
+ for k, v in updates.items():
548
+ if isinstance(v, dict) and isinstance(raw.get(k), dict):
549
+ merged = dict(raw[k])
550
+ for mk, mv in v.items():
551
+ # "" / None = hapus key (dipakai `/base -`).
552
+ if mv is None or (isinstance(mv, str) and not mv.strip()):
553
+ merged.pop(mk, None)
554
+ else:
555
+ merged[mk] = mv
556
+ raw[k] = merged
557
+ else:
558
+ raw[k] = v
559
+ path.parent.mkdir(parents=True, exist_ok=True)
560
+ path.write_text(yaml.safe_dump(raw, sort_keys=False, allow_unicode=True),
561
+ encoding="utf-8")
562
+
563
+
487
564
  def load_config(explicit_path: Path | str | None = None) -> Config:
488
565
  """Baca & validasi config. Exit(1) dengan pesan jelas kalau ada masalah."""
489
566
  path = resolve_config_path(explicit_path)
@@ -33,6 +33,7 @@ from core.providers import (
33
33
  ProviderError,
34
34
  ProviderNotFoundError,
35
35
  ProviderRateLimitError,
36
+ provider_id_of,
36
37
  resolve_provider,
37
38
  )
38
39
 
@@ -102,7 +103,13 @@ class LLMClient:
102
103
  langsung raise LLMError.
103
104
  """
104
105
  try:
105
- spec = resolve_provider(self.config.model, self.config.api_base)
106
+ prov = provider_id_of(self.config.model)
107
+ # Key/base per-provider (#31); fallback = top-level (kompat).
108
+ api_key = (self.config.provider_keys.get(prov)
109
+ or self.config.api_key) if prov else self.config.api_key
110
+ api_base = (self.config.provider_bases.get(prov)
111
+ or self.config.api_base) if prov else self.config.api_base
112
+ spec = resolve_provider(self.config.model, api_base)
106
113
  except ProviderError as e:
107
114
  raise LLMError(str(e)) from e
108
115
  if spec.kind == "anthropic":
@@ -116,7 +123,7 @@ class LLMClient:
116
123
  yielded_any_text = False
117
124
  try:
118
125
  async for event in stream_chat(
119
- spec, self.config.api_key, messages, tools,
126
+ spec, api_key, messages, tools,
120
127
  self.config.max_tokens, self.config.temperature,
121
128
  REQUEST_TIMEOUT):
122
129
  if isinstance(event, StreamText) and event.content: