sliceagent 0.1.7__tar.gz → 0.1.8__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.
- {sliceagent-0.1.7 → sliceagent-0.1.8}/CHANGELOG.md +25 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/PKG-INFO +1 -1
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/__init__.py +1 -1
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/cli.py +40 -10
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/config.py +6 -1
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/llm.py +6 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/onboarding.py +57 -7
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/policy.py +28 -4
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/tui.py +9 -2
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bughunt_fixes.py +4 -1
- sliceagent-0.1.8/tests/test_config_journey.py +260 -0
- sliceagent-0.1.7/tests/test_config_journey.py +0 -119
- {sliceagent-0.1.7 → sliceagent-0.1.8}/.dockerignore +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/.env.example +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/dependabot.yml +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/workflows/ci.yml +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/workflows/publish.yml +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/.gitignore +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/CODE_OF_CONDUCT.md +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/CONTRIBUTING.md +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/Dockerfile +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/LICENSE +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/NOTICE +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/QUICKSTART.md +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/README.md +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/SECURITY.md +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/examples/plugins/hello/__init__.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/examples/plugins/hello/plugin.toml +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/install.sh +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/pyproject.toml +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/scripts/gen_config_reference.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/scripts/run_tests.sh +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/sliceagent.toml.example +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/__main__.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/access.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/agents.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/background_review.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/binsniff.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/clock.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/code_grep.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/code_index.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/context_overflow.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/envspec.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/errors.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/events.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/finding_types.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/flags.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/fuzzy.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/guardrails.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/guidance.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/hippocampus.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/hooks.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/interfaces.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/loop.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/mcp_client.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/mcp_security.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/memory.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/metrics.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/model_catalog.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/monitor.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/neocortex.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/oracle.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/pagetable.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/pfc.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/plugins.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/procman.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/prompt.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/records.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/recovery.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/regions.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/registry.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/retriever.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/safety.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/sandbox.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/scheduler.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/search_index.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/seed.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/sensory_cortex.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/session.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/skill_provenance.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/skill_usage.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/skills.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/subagent.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/subdir_hints.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/swap.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/taskstate.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/telemetry.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/terminal.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/text_utils.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/tool_summary.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/tools.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/web.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/README.md +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_active_focus.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_agents.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_ask_echo.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_ask_user.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_background_review.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_binary_view.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bound_is_relevance.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_breadth_e2e.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_2b.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_breadth_wave.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_current_request.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_linenum.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_loop_wave.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_memory_wave.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_p0a.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_p0b.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_p0c.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_selfreview.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_selfreview_siblings.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_skills_h1.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_slice_wave.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_subagent_wave.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_tools_wave.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_cache_manifest.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_checkpoint.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_cli_smoke.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_closure.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_code_grep.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_code_index.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_consolidate.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_context_overflow.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_conversation.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_coresidency.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_echo_before_blocking.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_episode.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_errors_backoff.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_esc_sentinel.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_exec_env.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_finding_types.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_flags.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_fuzzy.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_ghost_index.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_guardrails.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_guidance.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_history.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_invariant_fixes.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_live_composer.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_llm_streaming.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_llm_usage_cache.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_llm_watchdog.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_log_redaction.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_loop_overflow.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_mcp_output_cap.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_mcp_runtime.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_memory.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_memory_persist.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_menu_select.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_metrics.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_mining.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_mission_tier.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_model_catalog.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_monitor.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_onboarding.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_pageout_compaction.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_permission_patterns.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_pinned_composer.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_plan_tier.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_procman.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_product_features.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_progress_status.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_prompt_ab.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_provenance.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_provider_lineup.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_proxy_interrupt.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_reach.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_readonly_subagent.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_recall_search.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_records.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_refault.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_registry_validation.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_reliability.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_repo_map_bounded.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_requirements.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_review_fixes.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_route_lexical.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_safety.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_seal_markdown.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_search_index.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_skill_meta.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_skill_metadata.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_subdir_hints.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_task_agnostic.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_task_state_roundtrip.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_telemetry_convergence.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_terminal.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tool_dedup.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tool_result_ok.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tools_robust.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_topic_switch.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_trajectory.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tui.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tui_menus.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tui_render.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tui_widgets.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_turn_budget.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_verification_agent.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_web.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_workspace.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_world_region.py +0 -0
- {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_world_state.py +0 -0
|
@@ -5,6 +5,31 @@ this project aims for [Semantic Versioning](https://semver.org/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.1.8] — 2026-07-02
|
|
9
|
+
|
|
10
|
+
Launch-day bug-hunt round: a security fix plus config-robustness and /model correctness.
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- `run_command` in the default *teenager* mode no longer auto-runs exec/write commands disguised as
|
|
14
|
+
"read-only": `env <program>`, `sort -o FILE`, `date -s`, `tree -o FILE`, `uniq IN OUT`, and
|
|
15
|
+
`git grep --open-files-in-pager` now take the confirm path (arbitrary code-exec / file-overwrite
|
|
16
|
+
confirm-bypass). Read-only siblings like `du -s` / `grep -o` still auto-run.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Boot no longer cross-wires a prefs-pinned provider's key with the DEFAULT provider's endpoint (an
|
|
20
|
+
env `gpt-5.5` @ OpenAI pin on a DeepSeek-default config used to 401 every call on relaunch).
|
|
21
|
+
- A prefs `provider`/model pin whose `[providers.<id>]` table was removed from config.toml is now
|
|
22
|
+
dropped at boot instead of forcing a model onto the wrong endpoint.
|
|
23
|
+
- The wizard no longer silently ERASES other providers' API keys when config.toml is unparseable —
|
|
24
|
+
the old file is moved to a non-clobbering `.bak` first (keys recoverable).
|
|
25
|
+
- `sliceagent config` / `config --use` / the init wizard no longer crash on a non-UTF-8 config.toml
|
|
26
|
+
or a scalar value under `[providers]` (they degrade).
|
|
27
|
+
- `llm.switch()` (a `/model` provider hop) closes the replaced HTTP client — no more fd leak per hop.
|
|
28
|
+
- The `/model` reasoning menu offers `high` (not a misleading `max`) for OpenRouter models, and offers
|
|
29
|
+
the levels the *target* provider supports when the pick rebinds the endpoint.
|
|
30
|
+
- The wizard's typed provider fallback rejects an unknown choice instead of silently configuring
|
|
31
|
+
OpenRouter.
|
|
32
|
+
|
|
8
33
|
## [0.1.7] — 2026-07-02
|
|
9
34
|
|
|
10
35
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sliceagent
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: A coding agent with a new context-engineering framework: bounded, deterministic, reconstructed context (slice architecture) — built for long-horizon work.
|
|
5
5
|
Author: TT-Wang
|
|
6
6
|
License-Expression: MIT
|
|
@@ -151,6 +151,21 @@ def _ws_name(path: str) -> str:
|
|
|
151
151
|
return "~" if p == os.path.realpath(os.path.expanduser("~")) else (os.path.basename(p) or p)
|
|
152
152
|
|
|
153
153
|
|
|
154
|
+
def _env_from_config(c, pid: str | None = None) -> None:
|
|
155
|
+
"""Populate LLM_API_KEY/LLM_BASE_URL from config (ENV still wins). A prefs-pinned provider `pid`
|
|
156
|
+
binds AS A UNIT: its key with ITS OWN endpoint — an absent base_url means the SDK default (OpenAI),
|
|
157
|
+
NOT the default provider's base_url (that fallback cross-wired an openai key onto the deepseek
|
|
158
|
+
endpoint after `/model`-hop + restart when default_provider pointed elsewhere)."""
|
|
159
|
+
tbl = (c.providers() or {}).get(pid) if pid else None
|
|
160
|
+
if isinstance(tbl, dict) and tbl.get("api_key"):
|
|
161
|
+
key, base = tbl["api_key"], tbl.get("base_url") or ""
|
|
162
|
+
else: # no/unusable pin → the default provider's own pairing
|
|
163
|
+
key, base = c.api_key, c.base_url
|
|
164
|
+
for _env, _val in (("LLM_API_KEY", key), ("LLM_BASE_URL", base)):
|
|
165
|
+
if not os.environ.get(_env) and _val:
|
|
166
|
+
os.environ[_env] = _val
|
|
167
|
+
|
|
168
|
+
|
|
154
169
|
def main() -> None:
|
|
155
170
|
# subcommands (onboarding / discovery) are handled BEFORE any key gate, so `sliceagent init` runs on a
|
|
156
171
|
# machine with nothing configured yet. A bare `sliceagent` (or one with non-subcommand args) falls through.
|
|
@@ -165,14 +180,6 @@ def main() -> None:
|
|
|
165
180
|
from .config import load_config, load_prefs
|
|
166
181
|
_boot_prefs = load_prefs() # the last /model choice may pin a PROVIDER (endpoint+key), not just a model
|
|
167
182
|
|
|
168
|
-
def _env_from_config(c, pid: str | None = None) -> None:
|
|
169
|
-
tbl = (c.providers() or {}).get(pid) if pid else None
|
|
170
|
-
key = (tbl or {}).get("api_key") or c.api_key
|
|
171
|
-
base = ((tbl or {}).get("base_url") if tbl and tbl.get("api_key") else None) or c.base_url
|
|
172
|
-
for _env, _val in (("LLM_API_KEY", key), ("LLM_BASE_URL", base)):
|
|
173
|
-
if not os.environ.get(_env) and _val:
|
|
174
|
-
os.environ[_env] = _val
|
|
175
|
-
|
|
176
183
|
def _key_present() -> bool:
|
|
177
184
|
return bool(os.environ.get("LLM_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
178
185
|
or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("DEEPSEEK_API_KEY")
|
|
@@ -189,6 +196,14 @@ def main() -> None:
|
|
|
189
196
|
return run_init() == 0
|
|
190
197
|
|
|
191
198
|
cfg = load_config()
|
|
199
|
+
# A prefs `provider` pin whose [providers.<id>] table was since removed from config.toml is STALE:
|
|
200
|
+
# its key/endpoint already fall back to the default provider (see _env_from_config), but its pinned
|
|
201
|
+
# MODEL would still win at the resolution below and get sent to the wrong endpoint (model_not_found
|
|
202
|
+
# every turn). Drop the whole stale pin so model/endpoint resolve together from the live config.
|
|
203
|
+
if _boot_prefs.get("provider") and _boot_prefs["provider"] not in (cfg.providers() or {}):
|
|
204
|
+
from .config import save_prefs as _sp
|
|
205
|
+
_sp({"provider": None, "model": None}) # None DELETES (see save_prefs)
|
|
206
|
+
_boot_prefs.pop("provider", None); _boot_prefs.pop("model", None)
|
|
192
207
|
_env_from_config(cfg, _boot_prefs.get("provider"))
|
|
193
208
|
if not _key_present() and _first_run_setup("Welcome! No API key configured yet"):
|
|
194
209
|
cfg = load_config() # the wizard just wrote ~/.sliceagent/config.toml — pick it up
|
|
@@ -468,6 +483,18 @@ def main() -> None:
|
|
|
468
483
|
ans = input(f" ⚠ allow {name} {str(detail)[:60]!r}? ({reason}) [y]es/[n]o/[a]lways: ").strip().lower()
|
|
469
484
|
return {"y": "yes", "yes": "yes", "a": "always", "always": "always"}.get(ans, "no")
|
|
470
485
|
|
|
486
|
+
def _live_pid():
|
|
487
|
+
"""The configured provider the LIVE llm is actually bound to (endpoint+key match), or None.
|
|
488
|
+
Prefs must pin what's IN EFFECT, not the last menu pick — a typed `/model <name>` keeps the
|
|
489
|
+
endpoint, so blindly re-saving the old pin resurrected a dead endpoint+model pairing at boot."""
|
|
490
|
+
lbase = getattr(llm, "_base_url", "") or ""
|
|
491
|
+
lkey = getattr(llm.client, "api_key", None)
|
|
492
|
+
for p, t in (cfg.providers() or {}).items():
|
|
493
|
+
if isinstance(t, dict) and t.get("api_key") and t["api_key"] == lkey \
|
|
494
|
+
and (t.get("base_url") or "") == lbase:
|
|
495
|
+
return p
|
|
496
|
+
return None
|
|
497
|
+
|
|
471
498
|
def _handle_slash(line): # TUI navigation palette — wired to existing session ops
|
|
472
499
|
nonlocal cfg # /config hot-reloads the config after the in-session wizard
|
|
473
500
|
parts = line.split(maxsplit=1)
|
|
@@ -595,8 +622,9 @@ def main() -> None:
|
|
|
595
622
|
_kw = {"base_url": _tbl.get("base_url") or "", "api_key": _tbl["api_key"]}
|
|
596
623
|
llm.switch(model=_model, reasoning=_reasoning, **_kw)
|
|
597
624
|
_stats["model"] = llm.model
|
|
625
|
+
# pin the provider ACTUALLY in effect (None DELETES a stale pin — see save_prefs)
|
|
598
626
|
save_prefs({"model": llm.model, "reasoning": llm.reasoning,
|
|
599
|
-
|
|
627
|
+
"provider": _pid or _live_pid()})
|
|
600
628
|
note = _reasoning_note(llm)
|
|
601
629
|
_console.print(f" ✓ model → [bold]{llm.model}[/]"
|
|
602
630
|
+ (f" @ [bold]{_pid}[/]" if _pid else "")
|
|
@@ -619,7 +647,9 @@ def main() -> None:
|
|
|
619
647
|
_console.print(" effort must be one of: fast | full | high | max"); return True
|
|
620
648
|
llm.switch(model=name, reasoning=eff)
|
|
621
649
|
_stats["model"] = llm.model
|
|
622
|
-
|
|
650
|
+
# typed /model keeps the endpoint — re-resolve the pin against the LIVE binding so a
|
|
651
|
+
# stale provider pin can't resurrect the old endpoint under this model at next boot
|
|
652
|
+
save_prefs({"model": llm.model, "reasoning": llm.reasoning, "provider": _live_pid()})
|
|
623
653
|
note = _reasoning_note(llm)
|
|
624
654
|
_console.print(f" ✓ model → [bold]{llm.model}[/]"
|
|
625
655
|
+ (f" · reasoning [bold]{llm.reasoning}[/]" if eff else "")
|
|
@@ -52,12 +52,17 @@ def load_prefs() -> dict:
|
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
def save_prefs(updates: dict) -> None:
|
|
55
|
-
"""Merge non-empty `updates` into the prefs sidecar (atomic write)
|
|
55
|
+
"""Merge non-empty `updates` into the prefs sidecar (atomic write); an explicit None DELETES the
|
|
56
|
+
key (a stale `provider` pin must be removable — merge-only let an old endpoint pin resurrect at
|
|
57
|
+
the next boot under a model it doesn't serve). Best-effort; never raises."""
|
|
56
58
|
try:
|
|
57
59
|
import json
|
|
58
60
|
path = _prefs_path()
|
|
59
61
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
60
62
|
cur = load_prefs()
|
|
63
|
+
for k, v in updates.items():
|
|
64
|
+
if v is None:
|
|
65
|
+
cur.pop(k, None)
|
|
61
66
|
cur.update({k: v for k, v in updates.items() if v})
|
|
62
67
|
tmp = path + ".tmp"
|
|
63
68
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
@@ -283,7 +283,13 @@ class OpenAILLM:
|
|
|
283
283
|
ckw: dict = {"api_key": new_key}
|
|
284
284
|
if new_base:
|
|
285
285
|
ckw["base_url"] = new_base
|
|
286
|
+
old_client = getattr(self, "client", None)
|
|
286
287
|
self.client = OpenAI(http_client=http_client, timeout=timeout, max_retries=2, **ckw)
|
|
288
|
+
if old_client is not None: # close the replaced connection pool — /model hops leaked fds
|
|
289
|
+
try:
|
|
290
|
+
old_client.close()
|
|
291
|
+
except Exception: # noqa: BLE001 — cleanup must never break the switch
|
|
292
|
+
pass
|
|
287
293
|
self._base_url = new_base or ""
|
|
288
294
|
self._drop_reasoning_effort = False
|
|
289
295
|
if model:
|
|
@@ -140,10 +140,42 @@ def _read_config(path: str) -> dict:
|
|
|
140
140
|
try:
|
|
141
141
|
with open(path, "rb") as f:
|
|
142
142
|
return tomllib.load(f)
|
|
143
|
-
except (OSError, tomllib.TOMLDecodeError):
|
|
143
|
+
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
|
|
144
|
+
# sibling parity with config.py:_read_toml — a non-UTF-8 or malformed config must DEGRADE, not
|
|
145
|
+
# crash `sliceagent config` / `config --use` / the init wizard with a traceback.
|
|
144
146
|
return {}
|
|
145
147
|
|
|
146
148
|
|
|
149
|
+
def _backup_if_corrupt(path: str) -> "str | None":
|
|
150
|
+
"""A rewrite reads the existing config to MERGE into; but _read_config returns {} for BOTH 'missing'
|
|
151
|
+
and 'unparseable', so a rewrite over a corrupt file would silently ERASE every provider key + section
|
|
152
|
+
it couldn't parse. Guard the writers: if `path` exists and is non-empty but does NOT parse, move it
|
|
153
|
+
aside to a non-clobbering .bak first (the keys survive, recoverable) and return the backup path; else
|
|
154
|
+
None (absent / empty / valid — nothing to protect)."""
|
|
155
|
+
import tomllib
|
|
156
|
+
try:
|
|
157
|
+
with open(path, "rb") as f:
|
|
158
|
+
raw = f.read()
|
|
159
|
+
except OSError:
|
|
160
|
+
return None
|
|
161
|
+
if not raw.strip():
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
tomllib.loads(raw.decode("utf-8"))
|
|
165
|
+
return None # parses fine → a normal merge is safe
|
|
166
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
|
|
167
|
+
pass
|
|
168
|
+
bak, n = path + ".bak", 0
|
|
169
|
+
while os.path.exists(bak): # never clobber an earlier backup
|
|
170
|
+
n += 1
|
|
171
|
+
bak = f"{path}.bak.{n}"
|
|
172
|
+
try:
|
|
173
|
+
os.replace(path, bak)
|
|
174
|
+
return bak
|
|
175
|
+
except OSError:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
147
179
|
def _atomic_write(path: str, body: str) -> None:
|
|
148
180
|
"""ATOMIC 0600 write (the file holds an API key — never leave it half-written): write a temp in the same
|
|
149
181
|
dir, fsync, then os.replace(); on ANY failure remove the temp so no key-bearing fragment is left behind."""
|
|
@@ -170,8 +202,10 @@ def _atomic_write(path: str, body: str) -> None:
|
|
|
170
202
|
pass
|
|
171
203
|
|
|
172
204
|
|
|
173
|
-
def _save_provider(path: str, *, pid: str, model: str, api_key: str, base_url: str) -> None:
|
|
174
|
-
"""Merge a provider into the config: add/update [providers.<pid>], set it as the default, keep the rest.
|
|
205
|
+
def _save_provider(path: str, *, pid: str, model: str, api_key: str, base_url: str) -> "str | None":
|
|
206
|
+
"""Merge a provider into the config: add/update [providers.<pid>], set it as the default, keep the rest.
|
|
207
|
+
Returns the path of a backup taken when the existing config was unparseable (else None)."""
|
|
208
|
+
bak = _backup_if_corrupt(path) # never silently erase keys in a corrupt config
|
|
175
209
|
data = _read_config(path)
|
|
176
210
|
provs = data.setdefault("providers", {})
|
|
177
211
|
if not isinstance(provs, dict): # a corrupt non-dict providers must not crash on provs[pid]=
|
|
@@ -186,6 +220,7 @@ def _save_provider(path: str, *, pid: str, model: str, api_key: str, base_url: s
|
|
|
186
220
|
agent["default_provider"] = pid
|
|
187
221
|
agent["model"] = model # keep top-level model in sync (back-compat)
|
|
188
222
|
_atomic_write(path, _emit_toml(data))
|
|
223
|
+
return bak
|
|
189
224
|
|
|
190
225
|
|
|
191
226
|
def _test_key(model: str, api_key: str, base_url: str, llm_factory) -> tuple[bool, str]:
|
|
@@ -250,8 +285,20 @@ def run_init(*, inp=input, getpw=None, llm_factory=None, home=None) -> int:
|
|
|
250
285
|
for k in keys:
|
|
251
286
|
_id, label, _base, model0 = PROVIDERS[k]
|
|
252
287
|
out(f" {k}. {label}" + (f" ({model0})" if model0 else ""))
|
|
253
|
-
|
|
254
|
-
|
|
288
|
+
# an unrecognized answer must NOT silently become provider 1 (a typed "openai" used to
|
|
289
|
+
# configure OpenRouter without a word) — accept a number OR a provider id, else re-ask.
|
|
290
|
+
choice = None
|
|
291
|
+
for _attempt in range(3):
|
|
292
|
+
raw = inp(" > ").strip() or "1"
|
|
293
|
+
if raw in PROVIDERS:
|
|
294
|
+
choice = raw; break
|
|
295
|
+
byname = next((k for k in keys if PROVIDERS[k][0] == raw.lower()), None)
|
|
296
|
+
if byname:
|
|
297
|
+
choice = byname; break
|
|
298
|
+
out(f" not a valid choice: {raw!r} — enter 1-{len(PROVIDERS)} or a provider name")
|
|
299
|
+
if choice is None:
|
|
300
|
+
out(" no valid choice — aborting."); return 1
|
|
301
|
+
pid, label, base_url, model = PROVIDERS[choice]
|
|
255
302
|
if pid == "custom":
|
|
256
303
|
base_url = inp(" Base URL (OpenAI-compatible, e.g. https://host/v1): ").strip()
|
|
257
304
|
# RE-CONFIGURING an ALREADY-SAVED provider (re-running `sliceagent init` to update the model,
|
|
@@ -303,7 +350,9 @@ def run_init(*, inp=input, getpw=None, llm_factory=None, home=None) -> int:
|
|
|
303
350
|
if cont not in ("y", "yes"):
|
|
304
351
|
out(" Not saved. Re-run `sliceagent init` to try again."); return 1
|
|
305
352
|
|
|
306
|
-
_save_provider(path, pid=pid, model=model, api_key=key, base_url=base_url)
|
|
353
|
+
bak = _save_provider(path, pid=pid, model=model, api_key=key, base_url=base_url)
|
|
354
|
+
if bak:
|
|
355
|
+
out(f" ⚠ the existing config didn't parse — moved it to {bak} (its keys are recoverable there).")
|
|
307
356
|
out(f"\n Saved provider '{pid}' (model {model}) → {path} (0600)")
|
|
308
357
|
out(" Ready. Run: sliceagent\n")
|
|
309
358
|
return 0
|
|
@@ -356,7 +405,8 @@ def run_config(argv=None, *, home=None, env=None) -> int:
|
|
|
356
405
|
out(" providers (* = default · `config --use <id>` to switch):")
|
|
357
406
|
for pid, p in provs.items():
|
|
358
407
|
mark = "*" if pid == default else " "
|
|
359
|
-
|
|
408
|
+
model = p.get("model", "?") if isinstance(p, dict) else "?" # a scalar under [providers] must not crash
|
|
409
|
+
out(f" {mark} {pid} ({model})")
|
|
360
410
|
out(" set values:")
|
|
361
411
|
any_set = False
|
|
362
412
|
for e in REGISTRY:
|
|
@@ -96,11 +96,30 @@ def ask_mutations(name: str, args: dict) -> Optional[ToolDecision]:
|
|
|
96
96
|
# mode runs it without a confirm prompt. Deny-by-default: an unknown verb still asks. The catastrophic floor
|
|
97
97
|
# (no_dangerous_commands) runs BEFORE this, so e.g. `cat /etc/passwd` is still denied.
|
|
98
98
|
_RO_VERBS = frozenset((
|
|
99
|
-
"ls", "pwd", "cat", "head", "tail", "wc", "echo", "which", "type", "
|
|
99
|
+
"ls", "pwd", "cat", "head", "tail", "wc", "echo", "which", "type", "printenv", "date",
|
|
100
100
|
"whoami", "hostname", "uname", "id", "groups", "tree", "stat", "file", "du", "df", "realpath",
|
|
101
|
-
"dirname", "basename", "grep", "rg", "egrep", "fgrep", "sort", "
|
|
101
|
+
"dirname", "basename", "grep", "rg", "egrep", "fgrep", "sort", "nl", "cut", "column",
|
|
102
102
|
"cksum", "md5", "md5sum", "sha1sum", "sha256sum", "true",
|
|
103
103
|
))
|
|
104
|
+
# `env` and `uniq` were REMOVED from the allowlist: `env <program>` executes an arbitrary program, and
|
|
105
|
+
# `uniq [IN] OUT` OVERWRITES its optional second positional — neither is provably read-only, so they now
|
|
106
|
+
# take the normal confirm path. A few remaining allowlisted verbs stay read-only ONLY without a specific
|
|
107
|
+
# WRITE/EXEC option; deny-by-default when that option is present (verb-scoped so read-only siblings like
|
|
108
|
+
# `du -s` / `grep -o` keep auto-running). Value-taking flags match `-o`, `-o=…`, `-oFILE`, `--output=…`.
|
|
109
|
+
_UNSAFE_OPTS = {
|
|
110
|
+
"sort": ("-o", "--output"), # -o FILE / --output=FILE overwrite a file
|
|
111
|
+
"date": ("-s", "--set"), # set the system clock (a mutation)
|
|
112
|
+
"tree": ("-o",), # -o FILE writes the listing to a file
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _has_unsafe_opt(verb: str, toks: list) -> bool:
|
|
117
|
+
bad = _UNSAFE_OPTS.get(verb, ())
|
|
118
|
+
for t in toks[1:]:
|
|
119
|
+
for b in bad:
|
|
120
|
+
if t == b or t.startswith(b + "=") or (len(b) == 2 and len(t) > 2 and t.startswith(b)):
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
104
123
|
# git subcommands with NO mutating form (excludes branch/tag/config/remote/stash/reflog, which can write).
|
|
105
124
|
_RO_GIT_SUB = frozenset((
|
|
106
125
|
"status", "log", "diff", "show", "rev-parse", "ls-files", "ls-tree", "describe", "blame",
|
|
@@ -127,10 +146,15 @@ def _is_readonly_command(cmd: str) -> bool:
|
|
|
127
146
|
verb = os.path.basename(toks[0])
|
|
128
147
|
if verb == "git":
|
|
129
148
|
sub = next((t for t in toks[1:] if not t.startswith("-")), "")
|
|
130
|
-
|
|
149
|
+
if sub not in _RO_GIT_SUB:
|
|
150
|
+
return False
|
|
151
|
+
# `git grep --open-files-in-pager[=<cmd>]` / `-O[<cmd>]` runs a PAGER program (arbitrary exec).
|
|
152
|
+
if sub == "grep" and any(t.startswith("--open-files-in-pager") or t.startswith("-O") for t in toks):
|
|
153
|
+
return False
|
|
154
|
+
return True
|
|
131
155
|
if verb == "find":
|
|
132
156
|
return not any(t in _FIND_MUTATORS for t in toks)
|
|
133
|
-
return verb in _RO_VERBS
|
|
157
|
+
return verb in _RO_VERBS and not _has_unsafe_opt(verb, toks)
|
|
134
158
|
|
|
135
159
|
|
|
136
160
|
def ask_commands(name: str, args: dict) -> Optional[ToolDecision]:
|
|
@@ -864,12 +864,16 @@ def _model_candidates(llm, cfg):
|
|
|
864
864
|
|
|
865
865
|
def _reasoning_levels(model, base_url):
|
|
866
866
|
"""Reasoning levels valid for a model, derived from its capability (provider-aware). Effort-capable
|
|
867
|
-
models (gpt-5/o-series) expose all four;
|
|
867
|
+
models (gpt-5/o-series) expose all four; OpenRouter exposes fast/full/high (its unified reasoning
|
|
868
|
+
object maps max→high, so offering max would be a lie); others only fast/full (high/max would
|
|
869
|
+
degrade to default)."""
|
|
868
870
|
from .model_catalog import capability
|
|
869
871
|
full4 = [("fast", "minimal reasoning — fastest, cheapest"),
|
|
870
872
|
("full", "provider default reasoning"),
|
|
871
873
|
("high", "deeper reasoning (effort=high, /v1/responses)"),
|
|
872
874
|
("max", "deepest reasoning (effort=xhigh)")]
|
|
875
|
+
if "openrouter" in (base_url or "").lower(): # unified reasoning object honors effort WITH tools
|
|
876
|
+
return full4[:2] + [("high", "deeper reasoning (unified reasoning, effort=high)")]
|
|
873
877
|
if capability(model, base_url).supports_reasoning_effort:
|
|
874
878
|
return full4
|
|
875
879
|
return full4[:2] # fast | full only — the model has no effort knob
|
|
@@ -888,7 +892,10 @@ def select_model_reasoning(llm, cfg, *, pt_input=None, pt_output=None):
|
|
|
888
892
|
if pick is None:
|
|
889
893
|
return None
|
|
890
894
|
model, _grp, pid = cands[pick]
|
|
891
|
-
|
|
895
|
+
if pid: # the pick will REBIND to this provider — offer the levels ITS endpoint supports
|
|
896
|
+
base = ((cfg.providers() or {}).get(pid) or {}).get("base_url") or ""
|
|
897
|
+
else:
|
|
898
|
+
base = getattr(llm, "_base_url", "") if model == llm.model else ""
|
|
892
899
|
levels = _reasoning_levels(model, base)
|
|
893
900
|
lvl_rows = [(name, desc) for name, desc in levels]
|
|
894
901
|
lvl_cur = next((i for i, (n, _) in enumerate(levels) if n == llm.reasoning), -1)
|
|
@@ -918,7 +918,10 @@ def cost_chart_renders_flat_vs_rising():
|
|
|
918
918
|
import sys
|
|
919
919
|
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
920
920
|
sys.path.insert(0, os.path.join(root, "evals"))
|
|
921
|
-
|
|
921
|
+
try:
|
|
922
|
+
import realenv_multiturn as rem # importable WITHOUT the heavy swebench dep
|
|
923
|
+
except ModuleNotFoundError:
|
|
924
|
+
return # evals/ is a local-only dev tree, not shipped in the installed package (CI) — skip, don't fail
|
|
922
925
|
rows = [{"turn": i, "peak_in": 6000, "transcript": 6000 * i} for i in range(1, 13)]
|
|
923
926
|
chart = rem.render_cost_chart(rows)
|
|
924
927
|
assert "sliceagent" in chart and "transcript" in chart and "12×" in chart, chart
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""The clear config journey (user-designed): /config manages providers in-session, and /model shows
|
|
2
|
+
ONLY configured providers' models — picking one switches model + endpoint + key together.
|
|
3
|
+
Offline: stub objects, no network, no pytest. Run: PYTHONPATH=src python tests/test_config_journey.py
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
9
|
+
|
|
10
|
+
from sliceagent.config import Config # noqa: E402
|
|
11
|
+
from sliceagent.llm import OpenAILLM # noqa: E402
|
|
12
|
+
from sliceagent.tui import _SLASH, _model_candidates, select_model_reasoning # noqa: E402
|
|
13
|
+
|
|
14
|
+
CHECKS = []
|
|
15
|
+
def check(fn):
|
|
16
|
+
CHECKS.append(fn)
|
|
17
|
+
return fn
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Llm:
|
|
21
|
+
model = "deepseek-chat"
|
|
22
|
+
_base_url = "https://api.deepseek.com/v1"
|
|
23
|
+
reasoning = "full"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_CFG = Config({"providers": {
|
|
27
|
+
"deepseek": {"api_key": "k1", "base_url": "https://api.deepseek.com/v1", "model": "deepseek-chat"},
|
|
28
|
+
"openrouter": {"api_key": "k2", "base_url": "https://openrouter.ai/api/v1", "model": "openai/gpt-5.5"},
|
|
29
|
+
"keyless": {"model": "phantom-model"}, # no api_key → NOT usable → must not appear
|
|
30
|
+
}})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@check
|
|
34
|
+
def model_menu_lists_only_configured_providers():
|
|
35
|
+
cands = _model_candidates(_Llm(), _CFG)
|
|
36
|
+
pids = {pid for _, _, pid in cands if pid}
|
|
37
|
+
models = [m for m, _, _ in cands]
|
|
38
|
+
assert pids == {"deepseek", "openrouter"}, f"only keyed providers may appear, got {pids}"
|
|
39
|
+
assert "phantom-model" not in models, "a provider without a key must not contribute models"
|
|
40
|
+
assert "deepseek-chat" in models and "openai/gpt-5.5" in models
|
|
41
|
+
assert "anthropic/claude-sonnet-5" in models, "configured providers contribute their suggestions too"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@check
|
|
45
|
+
def every_candidate_carries_its_provider_for_endpoint_switch():
|
|
46
|
+
for m, _grp, pid in _model_candidates(_Llm(), _CFG):
|
|
47
|
+
assert pid in ("deepseek", "openrouter"), f"{m} must map to a configured provider, got {pid!r}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@check
|
|
51
|
+
def env_current_model_is_labeled_honestly_not_as_a_provider():
|
|
52
|
+
class _EnvLlm: # env model NOT in any configured provider's list
|
|
53
|
+
model = "gpt-5.5"
|
|
54
|
+
_base_url = "https://api.deepseek.com/v1" # deepseek endpoint must NOT make it say "deepseek"
|
|
55
|
+
reasoning = "full"
|
|
56
|
+
cands = _model_candidates(_EnvLlm(), _CFG)
|
|
57
|
+
m, grp, pid = cands[0]
|
|
58
|
+
assert m == "gpt-5.5" and pid is None
|
|
59
|
+
assert grp == "current (env)", f"env model must not masquerade as a configured provider, got {grp!r}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@check
|
|
63
|
+
def no_providers_configured_falls_back_to_known_set():
|
|
64
|
+
cands = _model_candidates(_Llm(), Config({}))
|
|
65
|
+
assert cands, "env-only setups still need a menu"
|
|
66
|
+
assert all(pid is None for _, _, pid in cands), "fallback entries have no provider to rebind to"
|
|
67
|
+
assert any(m == "deepseek-chat" for m, _, _ in cands)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@check
|
|
71
|
+
def switch_rebinds_endpoint_and_key():
|
|
72
|
+
saved = {k: os.environ.pop(k, None) for k in ("AGENT_PROXY", "HTTPS_PROXY", "HTTP_PROXY")}
|
|
73
|
+
try:
|
|
74
|
+
llm = OpenAILLM(model="deepseek-chat", api_key="k1", base_url="https://api.deepseek.com/v1")
|
|
75
|
+
assert llm._base_url == "https://api.deepseek.com/v1"
|
|
76
|
+
llm.switch(model="openai/gpt-5.5", base_url="https://openrouter.ai/api/v1", api_key="k2")
|
|
77
|
+
assert llm.model == "openai/gpt-5.5"
|
|
78
|
+
assert llm._base_url == "https://openrouter.ai/api/v1"
|
|
79
|
+
assert str(llm.client.base_url).startswith("https://openrouter.ai/api/v1"), \
|
|
80
|
+
"the OpenAI client itself must be rebuilt against the new endpoint"
|
|
81
|
+
assert llm.client.api_key == "k2", "the key must follow the provider"
|
|
82
|
+
# base_url="" → SDK default endpoint (OpenAI)
|
|
83
|
+
llm.switch(model="gpt-5.5", base_url="", api_key="k3")
|
|
84
|
+
assert llm._base_url == "" and "openrouter" not in str(llm.client.base_url)
|
|
85
|
+
# model-only switch keeps the endpoint (the classic /model <name> path)
|
|
86
|
+
llm.switch(model="gpt-5")
|
|
87
|
+
assert llm.model == "gpt-5" and llm.client.api_key == "k3"
|
|
88
|
+
finally:
|
|
89
|
+
for k, v in saved.items():
|
|
90
|
+
if v is not None:
|
|
91
|
+
os.environ[k] = v
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@check
|
|
95
|
+
def selector_returns_the_provider_triple():
|
|
96
|
+
from prompt_toolkit.input.defaults import create_pipe_input
|
|
97
|
+
from prompt_toolkit.output import DummyOutput
|
|
98
|
+
with create_pipe_input() as pinp:
|
|
99
|
+
pinp.send_text("\r\r") # Enter (model) + Enter (reasoning)
|
|
100
|
+
got = select_model_reasoning(_Llm(), _CFG, pt_input=pinp, pt_output=DummyOutput())
|
|
101
|
+
assert got is not None and len(got) == 3, f"expected (model, reasoning, pid), got {got!r}"
|
|
102
|
+
model, _reasoning, pid = got
|
|
103
|
+
assert pid in ("deepseek", "openrouter") and isinstance(model, str)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@check
|
|
107
|
+
def config_command_is_in_the_slash_palette():
|
|
108
|
+
assert "/config" in _SLASH, "/config must be discoverable in the palette"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@check
|
|
112
|
+
def boot_binds_pinned_provider_as_a_unit_never_default_providers_base():
|
|
113
|
+
"""The cross-wiring bug: prefs pin openai (no base_url = SDK default) while default_provider is
|
|
114
|
+
deepseek → boot must NOT pair the openai key with the deepseek base_url."""
|
|
115
|
+
from sliceagent.cli import _env_from_config
|
|
116
|
+
c = Config({"providers": {
|
|
117
|
+
"openai": {"api_key": "sk-openai", "model": "gpt-5.5"},
|
|
118
|
+
"deepseek": {"api_key": "sk-ds", "base_url": "https://api.deepseek.com/v1", "model": "deepseek-chat"},
|
|
119
|
+
}, "agent": {"default_provider": "deepseek"}})
|
|
120
|
+
saved = {k: os.environ.pop(k, None) for k in ("LLM_API_KEY", "LLM_BASE_URL")}
|
|
121
|
+
try:
|
|
122
|
+
_env_from_config(c, "openai")
|
|
123
|
+
assert os.environ.get("LLM_API_KEY") == "sk-openai"
|
|
124
|
+
assert not os.environ.get("LLM_BASE_URL"), \
|
|
125
|
+
f"openai pin must leave base at SDK default, got {os.environ.get('LLM_BASE_URL')!r}"
|
|
126
|
+
os.environ.pop("LLM_API_KEY", None); os.environ.pop("LLM_BASE_URL", None)
|
|
127
|
+
_env_from_config(c, "deepseek") # a pin WITH base_url binds its own pair
|
|
128
|
+
assert os.environ.get("LLM_API_KEY") == "sk-ds"
|
|
129
|
+
assert os.environ.get("LLM_BASE_URL") == "https://api.deepseek.com/v1"
|
|
130
|
+
os.environ.pop("LLM_API_KEY", None); os.environ.pop("LLM_BASE_URL", None)
|
|
131
|
+
_env_from_config(c, "gone") # deleted pin → default provider's own pair
|
|
132
|
+
assert os.environ.get("LLM_API_KEY") == "sk-ds" and "deepseek" in os.environ.get("LLM_BASE_URL", "")
|
|
133
|
+
finally:
|
|
134
|
+
for k in ("LLM_API_KEY", "LLM_BASE_URL"):
|
|
135
|
+
os.environ.pop(k, None)
|
|
136
|
+
for k, v in saved.items():
|
|
137
|
+
if v is not None:
|
|
138
|
+
os.environ[k] = v
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@check
|
|
142
|
+
def save_prefs_none_deletes_a_stale_pin():
|
|
143
|
+
import json
|
|
144
|
+
import tempfile
|
|
145
|
+
import sliceagent.config as _cfgmod
|
|
146
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
147
|
+
orig = _cfgmod._prefs_path
|
|
148
|
+
_cfgmod._prefs_path = lambda: os.path.join(tmp, "prefs.json")
|
|
149
|
+
try:
|
|
150
|
+
_cfgmod.save_prefs({"model": "a", "provider": "openrouter"})
|
|
151
|
+
_cfgmod.save_prefs({"model": "b", "provider": None}) # typed /model → pin removed
|
|
152
|
+
got = json.load(open(os.path.join(tmp, "prefs.json")))
|
|
153
|
+
assert got.get("model") == "b" and "provider" not in got, f"stale pin survived: {got}"
|
|
154
|
+
finally:
|
|
155
|
+
_cfgmod._prefs_path = orig
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@check
|
|
159
|
+
def openrouter_models_offer_high_reasoning_in_the_menu():
|
|
160
|
+
from sliceagent.tui import _reasoning_levels
|
|
161
|
+
names = [n for n, _ in _reasoning_levels("openai/gpt-5.5", "https://openrouter.ai/api/v1")]
|
|
162
|
+
assert "high" in names, "openrouter's unified reasoning honors high — the menu must offer it"
|
|
163
|
+
assert "max" not in names, "openrouter maps max→high; offering max would lie"
|
|
164
|
+
assert [n for n, _ in _reasoning_levels("deepseek-chat", "https://api.deepseek.com/v1")] == ["fast", "full"]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@check
|
|
168
|
+
def switch_closes_the_replaced_client():
|
|
169
|
+
saved = {k: os.environ.pop(k, None) for k in ("AGENT_PROXY", "HTTPS_PROXY", "HTTP_PROXY")}
|
|
170
|
+
try:
|
|
171
|
+
llm = OpenAILLM(model="deepseek-chat", api_key="k1", base_url="https://api.deepseek.com/v1")
|
|
172
|
+
first = llm.client
|
|
173
|
+
llm.switch(model="openai/gpt-5.5", base_url="https://openrouter.ai/api/v1", api_key="k2")
|
|
174
|
+
hc = getattr(first, "_client", None) # the underlying httpx client
|
|
175
|
+
assert hc is None or hc.is_closed, "the replaced client's connection pool must be closed"
|
|
176
|
+
assert llm.client is not first
|
|
177
|
+
finally:
|
|
178
|
+
for k, v in saved.items():
|
|
179
|
+
if v is not None:
|
|
180
|
+
os.environ[k] = v
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@check
|
|
184
|
+
def config_save_backs_up_a_corrupt_config_instead_of_erasing_keys():
|
|
185
|
+
import tempfile
|
|
186
|
+
from sliceagent.onboarding import _save_provider
|
|
187
|
+
with tempfile.TemporaryDirectory() as d:
|
|
188
|
+
p = os.path.join(d, "config.toml")
|
|
189
|
+
with open(p, "w") as f:
|
|
190
|
+
f.write('[providers.deepseek]\napi_key = "sk-PRECIOUS"\nmodel = "deepseek-chat"\n')
|
|
191
|
+
with open(p, "w") as f:
|
|
192
|
+
f.write('[providers.deepseek\napi_key = "sk-PRECIOUS"\n') # one-char TOML typo
|
|
193
|
+
bak = _save_provider(p, pid="openai", model="gpt-5.5", api_key="sk-new", base_url="")
|
|
194
|
+
assert bak and os.path.exists(bak), "an unparseable config must be moved aside, not erased"
|
|
195
|
+
assert "sk-PRECIOUS" in open(bak).read(), "the old key must survive in the backup"
|
|
196
|
+
assert "sk-new" in open(p).read(), "the new provider must still be written"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@check
|
|
200
|
+
def config_read_degrades_on_non_utf8_instead_of_crashing():
|
|
201
|
+
import tempfile
|
|
202
|
+
from sliceagent.onboarding import _read_config, run_config
|
|
203
|
+
with tempfile.TemporaryDirectory() as d:
|
|
204
|
+
cfgdir = os.path.join(d, ".sliceagent"); os.makedirs(cfgdir)
|
|
205
|
+
p = os.path.join(cfgdir, "config.toml")
|
|
206
|
+
with open(p, "wb") as f:
|
|
207
|
+
f.write(b'[agent]\nmodel = "caf\xe9"\n') # non-UTF-8 byte
|
|
208
|
+
assert _read_config(p) == {}, "_read_config must degrade, not raise UnicodeDecodeError"
|
|
209
|
+
assert run_config([], home=d) == 0, "`sliceagent config` must not crash on a non-utf8 file"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@check
|
|
213
|
+
def config_show_tolerates_a_scalar_under_providers():
|
|
214
|
+
import tempfile
|
|
215
|
+
from sliceagent.onboarding import run_config
|
|
216
|
+
with tempfile.TemporaryDirectory() as d:
|
|
217
|
+
cfgdir = os.path.join(d, ".sliceagent"); os.makedirs(cfgdir)
|
|
218
|
+
with open(os.path.join(cfgdir, "config.toml"), "w") as f:
|
|
219
|
+
f.write('[providers]\nfoo = "bar"\n') # scalar, not a table
|
|
220
|
+
assert run_config([], home=d) == 0, "a scalar under [providers] must not crash `config`"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@check
|
|
224
|
+
def readonly_allowlist_rejects_exec_and_write_affordances():
|
|
225
|
+
from sliceagent.policy import _is_readonly_command as ro
|
|
226
|
+
for bypass in ["env sh -c 'touch x'", "env python3 e.py", "sort -o important.py /dev/null",
|
|
227
|
+
"sort --output=x a", "date -s 2020-01-01", "tree -o out.txt",
|
|
228
|
+
"uniq /dev/null important.py", "git grep --open-files-in-pager=/tmp/e.sh x",
|
|
229
|
+
"git grep -Oless foo"]:
|
|
230
|
+
assert not ro(bypass), f"must NOT auto-run (execs/writes): {bypass!r}"
|
|
231
|
+
for ok in ["ls -la", "cat f", "du -s .", "grep -o pat file", "sort a.txt", "git grep foo",
|
|
232
|
+
"git log --oneline", "printenv PATH", "date", "tree -L 2", "wc -l f"]:
|
|
233
|
+
assert ro(ok), f"read-only command must still auto-run: {ok!r}"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@check
|
|
237
|
+
def wizard_typed_fallback_rejects_unknown_provider_choice():
|
|
238
|
+
"""A typed 'openai' must configure OPENAI (name accepted); pure garbage must abort, not silently
|
|
239
|
+
become OpenRouter."""
|
|
240
|
+
import tempfile
|
|
241
|
+
from sliceagent.onboarding import run_init
|
|
242
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
243
|
+
feed = iter(["garbage", "junk", "nope"]) # 3 strikes → abort
|
|
244
|
+
rc = run_init(inp=lambda *_: next(feed), getpw=lambda *_: "k", home=tmp)
|
|
245
|
+
assert rc == 1, f"3 invalid choices must abort, got rc={rc}"
|
|
246
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
247
|
+
feed = iter(["openai", "my-model", ""]) # name accepted; blank key → abort path
|
|
248
|
+
rc = run_init(inp=lambda *_: next(feed), getpw=lambda *_: "", home=tmp)
|
|
249
|
+
assert rc == 1, "no key entered must abort"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
ok = 0
|
|
254
|
+
for fn in CHECKS:
|
|
255
|
+
try:
|
|
256
|
+
fn(); ok += 1; print(f"PASS {fn.__name__}")
|
|
257
|
+
except Exception as e: # noqa: BLE001
|
|
258
|
+
print(f"FAIL {fn.__name__}: {type(e).__name__}: {e}")
|
|
259
|
+
print(f"\n{ok}/{len(CHECKS)} passed")
|
|
260
|
+
sys.exit(0 if ok == len(CHECKS) else 1)
|