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.
- {multacd-2.0.0b6 → multacd-2.0.0b8}/.github/workflows/release.yml +1 -1
- {multacd-2.0.0b6 → multacd-2.0.0b8}/CHANGELOG.md +18 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/PKG-INFO +1 -5
- multacd-2.0.0b8/core/agent_events.py +86 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/agent_loop.py +140 -46
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/config.py +83 -6
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/llm_client.py +9 -2
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/mode_manager.py +159 -5
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/providers/__init__.py +13 -0
- multacd-2.0.0b8/core/research/bus.py +66 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/research/orchestrator.py +28 -11
- multacd-2.0.0b8/core/session_state.py +127 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/version.py +1 -1
- {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/install.md +2 -2
- {multacd-2.0.0b6 → multacd-2.0.0b8}/pyproject.toml +1 -11
- multacd-2.0.0b8/requirements.txt +11 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/scripts/install.sh +1 -1
- multacd-2.0.0b8/search_providers/__init__.py +78 -0
- multacd-2.0.0b8/search_providers/exa.py +103 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/tavily.py +29 -10
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/integration/test_agent_loop.py +38 -7
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/test_selftests.py +5 -0
- multacd-2.0.0b8/tests/unit/test_agent_controller.py +118 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_config.py +31 -0
- multacd-2.0.0b8/tests/unit/test_provider_keys.py +142 -0
- multacd-2.0.0b8/tests/unit/test_research_bus.py +69 -0
- multacd-2.0.0b8/tests/unit/test_search_providers/test_fallback.py +81 -0
- multacd-2.0.0b8/tests/unit/test_session_state.py +66 -0
- multacd-2.0.0b8/tests/unit/test_splash.py +20 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/quick_research.py +9 -2
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/web_search.py +16 -21
- multacd-2.0.0b8/tui/controllers/agent_controller.py +184 -0
- multacd-2.0.0b8/tui/markup_safe.py +65 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/screens/main_screen.py +241 -59
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/screens/setup_wizard.py +13 -2
- multacd-2.0.0b8/tui/widgets/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/chat_panel.py +77 -20
- multacd-2.0.0b8/tui/widgets/confirm_dialog.py +113 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/info_panel.py +2 -1
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/model_selector.py +75 -5
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/permission_popup.py +15 -7
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/slash_palette.py +10 -4
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/sources_panel.py +5 -2
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/status_bar.py +31 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/thinking_bar.py +46 -1
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/tool_activity.py +43 -20
- multacd-2.0.0b6/core/research/bus.py +0 -41
- multacd-2.0.0b6/requirements.txt +0 -12
- multacd-2.0.0b6/search_providers/__init__.py +0 -41
- multacd-2.0.0b6/search_providers/exa.py +0 -99
- multacd-2.0.0b6/tui/widgets/confirm_dialog.py +0 -56
- {multacd-2.0.0b6 → multacd-2.0.0b8}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/.github/pull_request_template.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/.github/workflows/test.yml +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/.gitignore +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/CONTRIBUTING.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/DESIGN.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/LICENSE +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/README.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/generator.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/privacy.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/sources/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/sources/git_source.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/sources/news_source.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/briefing/sources/todo_source.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/clipboard.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/codebase.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/permissions.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/plugin_loader.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/prompt_composer.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/providers/__main__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/providers/anthropic.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/providers/openai_compat.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/research/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/research/query_generator.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/core/updater.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/daemon/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/daemon/ipc.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/daemon/process.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/configuration.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/index.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/mkdocs.yml +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/modes/coding.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/modes/personal.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/modes/research.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/plugins.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/docs/quickstart.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/main.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/memory/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/memory/context.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/memory/store.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/multacd.spec +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/plan/PLAN-phase1.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/plan/PLAN-phase2.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/plugins/README.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/plugins/example_plugin.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/roadmap/ROADMAP.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/scheduler/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/scheduler/engine.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/scheduler/jobs/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/scheduler/jobs/briefing_job.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/scheduler/jobs/research_job.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/scripts/install.ps1 +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/__main__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/base.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/brave.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/duckduckgo.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/search_providers/serpapi.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/soul.md +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/conftest.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/integration/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_binary.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_docs.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_entrypoint.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_github.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_installer.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_permissions.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_plugin_loader.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_privacy_filter.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_search_providers/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_search_providers/test_tavily.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_tools/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_tools/test_bash.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_tools/test_read_file.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_tools/test_web_scrape.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_tools/test_write_file.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tests/unit/test_updater.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/access_control.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/agent.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/bot.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/file_handler.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/formatter.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tg/handlers.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/agent/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/agent/ask.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/agent/skill.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/agent/task.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/agent/todo_write.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/code/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/code/lint_python.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/code/run_python.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/code/run_tests.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/codebase/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/codebase/scan_codebase.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/common.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/apply_patch.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/delete_file.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/edit_file.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/glob.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/grep.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/list_dir.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/move_file.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/multi_edit.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/read_file.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/read_many_files.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/filesystem/write_file.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/_helper.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_add.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_branch.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_checkout.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_commit.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_diff.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_log.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_merge.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_pull.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_push.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/git/git_status.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/memory/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/memory/forget.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/memory/recall.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/memory/remember.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/cancel_job.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/daemon_status.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/generate_briefing.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/get_jobs.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/schedule_job.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/send_telegram.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/personal/user_manager.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/registry.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/deep_research.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/export_research.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/research/web_scrape.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/shell/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/shell/bash.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/streaming.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/web/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tools/web/web_fetch.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/app.py +0 -0
- {multacd-2.0.0b6/tui/screens → multacd-2.0.0b8/tui/controllers}/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/icons.py +0 -0
- {multacd-2.0.0b6/tui/widgets → multacd-2.0.0b8/tui/screens}/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/themes/__init__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/themes/__main__.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/tokens.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/diff_viewer.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/file_tree.py +0 -0
- {multacd-2.0.0b6 → multacd-2.0.0b8}/tui/widgets/input_bar.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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"
|
|
190
|
-
"
|
|
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.
|
|
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],
|
|
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,
|
|
144
|
-
|
|
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,
|
|
163
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|