novacode-cli 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- novacode_cli/__init__.py +29 -0
- novacode_cli/__main__.py +6 -0
- novacode_cli/_lazy_heavy.py +99 -0
- novacode_cli/_version.py +3 -0
- novacode_cli/agent_stream.py +178 -0
- novacode_cli/agents/agent_file.py +250 -0
- novacode_cli/agents/agent_mailbox.py +120 -0
- novacode_cli/agents/approval_decisions.py +146 -0
- novacode_cli/agents/arg_repair.py +196 -0
- novacode_cli/agents/async_agents/__init__.py +6 -0
- novacode_cli/agents/async_agents/_config.py +213 -0
- novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
- novacode_cli/agents/async_agents/_specialist.py +84 -0
- novacode_cli/agents/async_agents/_test_commands.py +89 -0
- novacode_cli/agents/async_agents/_tools.py +432 -0
- novacode_cli/agents/async_agents/general_purpose.py +42 -0
- novacode_cli/agents/async_agents/langgraph.json +9 -0
- novacode_cli/agents/async_agents/research.py +22 -0
- novacode_cli/agents/async_context.py +303 -0
- novacode_cli/agents/async_on_demand.py +52 -0
- novacode_cli/agents/async_task_errors.py +75 -0
- novacode_cli/agents/async_workspace.py +27 -0
- novacode_cli/agents/attribution.py +165 -0
- novacode_cli/agents/browser_mode.py +119 -0
- novacode_cli/agents/btw_agent.py +71 -0
- novacode_cli/agents/caveman.py +352 -0
- novacode_cli/agents/caveman_support.py +370 -0
- novacode_cli/agents/code_mode.py +774 -0
- novacode_cli/agents/code_runtime.py +160 -0
- novacode_cli/agents/context_compaction.py +1339 -0
- novacode_cli/agents/core_agent.py +2710 -0
- novacode_cli/agents/default_subagents/__init__.py +24 -0
- novacode_cli/agents/default_subagents/async_subagents.py +296 -0
- novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
- novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
- novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
- novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
- novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
- novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
- novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
- novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
- novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
- novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
- novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
- novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
- novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
- novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
- novacode_cli/agents/default_subagents/prompt.py +88 -0
- novacode_cli/agents/default_subagents/subagents.py +138 -0
- novacode_cli/agents/dream_agent.py +93 -0
- novacode_cli/agents/edit_diagnostics.py +362 -0
- novacode_cli/agents/edit_fallback.py +62 -0
- novacode_cli/agents/handoff.py +166 -0
- novacode_cli/agents/invocation.py +152 -0
- novacode_cli/agents/model_budget.py +42 -0
- novacode_cli/agents/model_retry.py +113 -0
- novacode_cli/agents/model_router.py +420 -0
- novacode_cli/agents/openai_decisions.py +186 -0
- novacode_cli/agents/plan_agent/__init__.py +12 -0
- novacode_cli/agents/plan_agent/plan_agent.py +357 -0
- novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
- novacode_cli/agents/plan_execution.py +237 -0
- novacode_cli/agents/ponytail.py +66 -0
- novacode_cli/agents/prime.py +274 -0
- novacode_cli/agents/provider_caching.py +99 -0
- novacode_cli/agents/server_launcher.py +904 -0
- novacode_cli/agents/strict_tools.py +384 -0
- novacode_cli/agents/task_discipline.py +294 -0
- novacode_cli/agents/tool_call_repair.py +290 -0
- novacode_cli/agents/tool_offload.py +291 -0
- novacode_cli/agents/tool_search.py +806 -0
- novacode_cli/agents/tool_search_variants.py +106 -0
- novacode_cli/agents/tool_verdicts.py +1194 -0
- novacode_cli/agents/user_async_agents.py +312 -0
- novacode_cli/artifacts/__init__.py +10 -0
- novacode_cli/artifacts/registry.py +278 -0
- novacode_cli/artifacts/server.py +426 -0
- novacode_cli/assets/Nova.png +0 -0
- novacode_cli/assets/nova-avatar.png +0 -0
- novacode_cli/audio/__init__.py +301 -0
- novacode_cli/audio/capture.py +137 -0
- novacode_cli/audio/downloads.py +273 -0
- novacode_cli/audio/pipeline.py +391 -0
- novacode_cli/audio/playback.py +87 -0
- novacode_cli/audio/providers.py +208 -0
- novacode_cli/audio/speakable.py +78 -0
- novacode_cli/audio/stt.py +132 -0
- novacode_cli/audio/stt_deepgram.py +69 -0
- novacode_cli/audio/stt_elevenlabs.py +89 -0
- novacode_cli/audio/stt_parakeet.py +94 -0
- novacode_cli/audio/stt_whistle.py +61 -0
- novacode_cli/audio/summarize.py +92 -0
- novacode_cli/audio/tts.py +85 -0
- novacode_cli/audio/tts_kokoro.py +73 -0
- novacode_cli/audio/tts_orpheus.py +180 -0
- novacode_cli/audio/tts_pocket.py +131 -0
- novacode_cli/audio/tts_sano.py +69 -0
- novacode_cli/audio/vad.py +145 -0
- novacode_cli/backends/__init__.py +17 -0
- novacode_cli/backends/filesystem.py +894 -0
- novacode_cli/backends/native_search.py +92 -0
- novacode_cli/bootstrap/__init__.py +9 -0
- novacode_cli/bootstrap/env_snapshot.py +240 -0
- novacode_cli/bootstrap/steering.py +464 -0
- novacode_cli/bootstrap/vision_router.py +604 -0
- novacode_cli/brand.py +362 -0
- novacode_cli/cli_args.py +718 -0
- novacode_cli/commands/__init__.py +237 -0
- novacode_cli/commands/agents_commands.py +231 -0
- novacode_cli/commands/auth_handler.py +40 -0
- novacode_cli/commands/browser_use_handler.py +75 -0
- novacode_cli/commands/cache_handler.py +32 -0
- novacode_cli/commands/chat_handler.py +1357 -0
- novacode_cli/commands/ci_fix.py +171 -0
- novacode_cli/commands/claude_plan.py +331 -0
- novacode_cli/commands/codex_plan.py +195 -0
- novacode_cli/commands/commands.py +1113 -0
- novacode_cli/commands/create_handler.py +124 -0
- novacode_cli/commands/create_server.py +1887 -0
- novacode_cli/commands/cron_handler.py +119 -0
- novacode_cli/commands/dream_handler.py +203 -0
- novacode_cli/commands/effort_handler.py +65 -0
- novacode_cli/commands/evolution_handler.py +214 -0
- novacode_cli/commands/file_commands.py +381 -0
- novacode_cli/commands/handoff_handler.py +68 -0
- novacode_cli/commands/hooks_handler.py +269 -0
- novacode_cli/commands/init_handler.py +146 -0
- novacode_cli/commands/learning_handler.py +160 -0
- novacode_cli/commands/lite_mode_handler.py +76 -0
- novacode_cli/commands/log_commands.py +363 -0
- novacode_cli/commands/mcp_handler.py +29 -0
- novacode_cli/commands/model_handler.py +29 -0
- novacode_cli/commands/notifications_handler.py +158 -0
- novacode_cli/commands/onboard_handler.py +121 -0
- novacode_cli/commands/plan_handler.py +440 -0
- novacode_cli/commands/planner_usage.py +250 -0
- novacode_cli/commands/plugin_install_handler.py +38 -0
- novacode_cli/commands/plugins_handler.py +35 -0
- novacode_cli/commands/prime_handler.py +455 -0
- novacode_cli/commands/prompt_handler.py +149 -0
- novacode_cli/commands/provider_usage.py +160 -0
- novacode_cli/commands/ralph_events.py +106 -0
- novacode_cli/commands/ralph_handler.py +1293 -0
- novacode_cli/commands/refine_handler.py +213 -0
- novacode_cli/commands/research_handler.py +202 -0
- novacode_cli/commands/rootapp_remote.py +83 -0
- novacode_cli/commands/router_handler.py +36 -0
- novacode_cli/commands/server_commands.py +249 -0
- novacode_cli/commands/session_commands.py +203 -0
- novacode_cli/commands/share_handler.py +450 -0
- novacode_cli/commands/side_commands.py +366 -0
- novacode_cli/commands/skill_invoke.py +420 -0
- novacode_cli/commands/skills_commands.py +48 -0
- novacode_cli/commands/trace_handler.py +322 -0
- novacode_cli/commands/trello_handler.py +226 -0
- novacode_cli/commands/trello_remote.py +177 -0
- novacode_cli/commands/trello_server.py +1214 -0
- novacode_cli/commands/vision_handler.py +127 -0
- novacode_cli/commands/voice_handler.py +447 -0
- novacode_cli/commands/webhook_handler.py +134 -0
- novacode_cli/commands/wiki_commands.py +227 -0
- novacode_cli/compaction.py +753 -0
- novacode_cli/computation_cache.py +159 -0
- novacode_cli/computer/__init__.py +1 -0
- novacode_cli/computer/persistent.py +234 -0
- novacode_cli/computer/preview.py +253 -0
- novacode_cli/computer/runtime.py +474 -0
- novacode_cli/computer/windows_console.py +152 -0
- novacode_cli/computer/windows_qemu.py +211 -0
- novacode_cli/config/browser_mode.py +159 -0
- novacode_cli/config/catalog_models.py +95 -0
- novacode_cli/config/config.py +1388 -0
- novacode_cli/config/credentials.py +540 -0
- novacode_cli/config/google_oauth_auth.py +281 -0
- novacode_cli/config/model_capabilities.py +343 -0
- novacode_cli/config/model_catalog.py +275 -0
- novacode_cli/config/model_create.py +1325 -0
- novacode_cli/config/model_manager.py +695 -0
- novacode_cli/config/nova_config.py +1283 -0
- novacode_cli/config/openai_chatgpt_auth.py +384 -0
- novacode_cli/config/opencode_gateway.py +25 -0
- novacode_cli/config/plan_mode.py +188 -0
- novacode_cli/config/provider_auth.py +438 -0
- novacode_cli/config/provider_catalog.json +5127 -0
- novacode_cli/config/provider_registry.py +38 -0
- novacode_cli/config/provider_transports.py +258 -0
- novacode_cli/config/role_models.py +175 -0
- novacode_cli/context/__init__.py +50 -0
- novacode_cli/context/_analysis.py +799 -0
- novacode_cli/context/_budget.py +116 -0
- novacode_cli/context/_conversation.py +73 -0
- novacode_cli/context/_dynamic.py +264 -0
- novacode_cli/context/_model_config.py +380 -0
- novacode_cli/context/_models_dev.py +99 -0
- novacode_cli/context/archives.py +248 -0
- novacode_cli/context/estimates.py +65 -0
- novacode_cli/context/history.py +17 -0
- novacode_cli/context/knowledge_cache.py +292 -0
- novacode_cli/context/manager.py +101 -0
- novacode_cli/context/observations.py +65 -0
- novacode_cli/context/pressure.py +168 -0
- novacode_cli/context/provider_accounting.py +109 -0
- novacode_cli/context/snapshot.py +493 -0
- novacode_cli/context/strategies.py +279 -0
- novacode_cli/context/task_progress.py +122 -0
- novacode_cli/core/__init__.py +0 -0
- novacode_cli/core/agent_loop.py +1821 -0
- novacode_cli/core/autonomous_loop.py +277 -0
- novacode_cli/core/code_approvals.py +57 -0
- novacode_cli/core/input_preparation.py +276 -0
- novacode_cli/core/reasoning.py +67 -0
- novacode_cli/core/streaming.py +326 -0
- novacode_cli/core/subagent_tasks.py +696 -0
- novacode_cli/core/subagent_tracking.py +235 -0
- novacode_cli/core/tool_results.py +116 -0
- novacode_cli/core/verification_loop.py +272 -0
- novacode_cli/council.py +595 -0
- novacode_cli/council_consensus.py +115 -0
- novacode_cli/council_decision.py +157 -0
- novacode_cli/council_planner.py +1421 -0
- novacode_cli/council_settings.py +208 -0
- novacode_cli/council_web_settings.py +41 -0
- novacode_cli/council_workspace.py +56 -0
- novacode_cli/cowork/__init__.py +10 -0
- novacode_cli/cowork/broker_middleware.py +162 -0
- novacode_cli/cowork/launcher.py +102 -0
- novacode_cli/cowork/policy.py +284 -0
- novacode_cli/cowork/ui.py +826 -0
- novacode_cli/daemons/__init__.py +1 -0
- novacode_cli/daemons/registry.py +342 -0
- novacode_cli/desktop_notifications.py +302 -0
- novacode_cli/doctor.py +251 -0
- novacode_cli/embeddings.py +109 -0
- novacode_cli/entrypoint.py +56 -0
- novacode_cli/errors/__init__.py +19 -0
- novacode_cli/errors/handlers.py +648 -0
- novacode_cli/errors/provider_errors.py +249 -0
- novacode_cli/errors/taxonomy.py +80 -0
- novacode_cli/events.py +179 -0
- novacode_cli/file_ops.py +714 -0
- novacode_cli/git_safety.py +281 -0
- novacode_cli/handoff.py +383 -0
- novacode_cli/headless/__init__.py +18 -0
- novacode_cli/headless/input.py +58 -0
- novacode_cli/headless/output.py +224 -0
- novacode_cli/headless/pipe.py +532 -0
- novacode_cli/headless/runner.py +461 -0
- novacode_cli/hermes/__init__.py +39 -0
- novacode_cli/hermes/activation.py +297 -0
- novacode_cli/hermes/config.py +129 -0
- novacode_cli/hermes/curator.py +199 -0
- novacode_cli/hermes/evolution.py +374 -0
- novacode_cli/hermes/ledger.py +364 -0
- novacode_cli/hermes/memory_tiers.py +969 -0
- novacode_cli/hermes/middleware.py +678 -0
- novacode_cli/hermes/overview.py +174 -0
- novacode_cli/hermes/prompt_evolution.py +503 -0
- novacode_cli/hermes/refine_loop.py +643 -0
- novacode_cli/hermes/refinement_log.py +190 -0
- novacode_cli/hermes/review.py +674 -0
- novacode_cli/hermes/skill_debate.py +135 -0
- novacode_cli/hermes/skill_discovery.py +450 -0
- novacode_cli/hermes/skill_manager.py +335 -0
- novacode_cli/hermes/skill_proposals.py +43 -0
- novacode_cli/hermes/test_gaming.py +119 -0
- novacode_cli/hermes/tracker.py +435 -0
- novacode_cli/hermes/tuner.py +206 -0
- novacode_cli/hermes/verification_memory.py +134 -0
- novacode_cli/hermes/verifier.py +277 -0
- novacode_cli/hitl/__init__.py +22 -0
- novacode_cli/hitl/interrupts.py +493 -0
- novacode_cli/hooks.py +359 -0
- novacode_cli/image_utils.py +517 -0
- novacode_cli/init/__init__.py +7 -0
- novacode_cli/init/events.py +43 -0
- novacode_cli/input_utils.py +526 -0
- novacode_cli/install_commands.py +87 -0
- novacode_cli/integrations/__init__.py +82 -0
- novacode_cli/integrations/daytona.py +122 -0
- novacode_cli/integrations/docker.py +309 -0
- novacode_cli/integrations/host_path.py +111 -0
- novacode_cli/integrations/langsmith.py +151 -0
- novacode_cli/integrations/modal.py +134 -0
- novacode_cli/integrations/runloop.py +130 -0
- novacode_cli/integrations/sandbox_factory.py +1388 -0
- novacode_cli/integrations/sandbox_registry.py +312 -0
- novacode_cli/integrations/workdir_backend.py +589 -0
- novacode_cli/main.py +2078 -0
- novacode_cli/mcp/__init__.py +73 -0
- novacode_cli/mcp/auth.py +189 -0
- novacode_cli/mcp/client.py +571 -0
- novacode_cli/mcp/code_mode.py +5 -0
- novacode_cli/mcp/code_runtime.py +5 -0
- novacode_cli/mcp/commands.py +902 -0
- novacode_cli/mcp/config.py +406 -0
- novacode_cli/mcp/generic.py +128 -0
- novacode_cli/mcp/middleware.py +993 -0
- novacode_cli/mcp/presets.py +367 -0
- novacode_cli/memory/__init__.py +1 -0
- novacode_cli/memory/agent_memory.py +1312 -0
- novacode_cli/memory/builtin_lessons.py +60 -0
- novacode_cli/memory/dream_gates.py +192 -0
- novacode_cli/memory/limits.py +59 -0
- novacode_cli/memory/parsed_cache.py +106 -0
- novacode_cli/memory/recall_stats.py +97 -0
- novacode_cli/memory/semantic.py +116 -0
- novacode_cli/memory/store.py +335 -0
- novacode_cli/memory/wiki.py +274 -0
- novacode_cli/migrate.py +185 -0
- novacode_cli/onboarding.py +649 -0
- novacode_cli/onboarding_check.py +119 -0
- novacode_cli/path_approval.py +297 -0
- novacode_cli/plan_archive.py +148 -0
- novacode_cli/plugins/__init__.py +121 -0
- novacode_cli/plugins/claude_plugins.py +538 -0
- novacode_cli/plugins/cli.py +168 -0
- novacode_cli/plugins/components.py +413 -0
- novacode_cli/plugins/hooks.py +356 -0
- novacode_cli/plugins/loader.py +317 -0
- novacode_cli/plugins/marketplaces.py +199 -0
- novacode_cli/plugins/storage.py +70 -0
- novacode_cli/prime.py +759 -0
- novacode_cli/prime_factory/__init__.py +15 -0
- novacode_cli/prime_factory/container.py +198 -0
- novacode_cli/prime_factory/controller.py +565 -0
- novacode_cli/prime_factory/knowledge.py +261 -0
- novacode_cli/prime_factory/publication.py +218 -0
- novacode_cli/prime_factory/recovery.py +50 -0
- novacode_cli/prime_factory/research.py +370 -0
- novacode_cli/prime_factory/runner.py +608 -0
- novacode_cli/prime_factory/runtime.py +261 -0
- novacode_cli/prime_factory/state.py +201 -0
- novacode_cli/prime_factory/store.py +128 -0
- novacode_cli/prime_factory/worker.py +129 -0
- novacode_cli/prime_factory/workflow.py +899 -0
- novacode_cli/prime_factory/workspace.py +317 -0
- novacode_cli/prime_memory.py +290 -0
- novacode_cli/process_manager.py +585 -0
- novacode_cli/project_onboard.py +537 -0
- novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
- novacode_cli/prompts/__init__.py +168 -0
- novacode_cli/prompts/agent_definitions.jinja +11 -0
- novacode_cli/prompts/agent_generation.jinja +125 -0
- novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
- novacode_cli/prompts/claude_plan_mode.jinja +12 -0
- novacode_cli/prompts/coding_workflow.jinja +18 -0
- novacode_cli/prompts/computer_use.jinja +17 -0
- novacode_cli/prompts/core_agent_system.jinja +81 -0
- novacode_cli/prompts/delegation.jinja +15 -0
- novacode_cli/prompts/desktop_power.jinja +4 -0
- novacode_cli/prompts/file_paths.jinja +15 -0
- novacode_cli/prompts/file_tracker.jinja +23 -0
- novacode_cli/prompts/init_agents_md.jinja +56 -0
- novacode_cli/prompts/init_exploration.jinja +174 -0
- novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
- novacode_cli/prompts/instruction_scope.jinja +5 -0
- novacode_cli/prompts/learnings_extraction.jinja +45 -0
- novacode_cli/prompts/longterm_memory.jinja +54 -0
- novacode_cli/prompts/mcp.jinja +84 -0
- novacode_cli/prompts/memory_consolidation.jinja +40 -0
- novacode_cli/prompts/nova_evolution.jinja +73 -0
- novacode_cli/prompts/nova_review.jinja +158 -0
- novacode_cli/prompts/nova_skill_debate.jinja +36 -0
- novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
- novacode_cli/prompts/nova_verify.jinja +100 -0
- novacode_cli/prompts/onboard_dashboard.jinja +378 -0
- novacode_cli/prompts/plan_agent.jinja +87 -0
- novacode_cli/prompts/plan_scouts.jinja +5 -0
- novacode_cli/prompts/plan_storage.jinja +8 -0
- novacode_cli/prompts/planning.jinja +82 -0
- novacode_cli/prompts/ponytail/LICENSE +21 -0
- novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
- novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
- novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
- novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
- novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
- novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
- novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
- novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
- novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
- novacode_cli/prompts/prompt_evolution.jinja +27 -0
- novacode_cli/prompts/ralph_iteration.jinja +75 -0
- novacode_cli/prompts/refine_gate.jinja +31 -0
- novacode_cli/prompts/refine_plan.jinja +64 -0
- novacode_cli/prompts/refine_review.jinja +34 -0
- novacode_cli/prompts/research_delivery.jinja +9 -0
- novacode_cli/prompts/research_swarm.jinja +130 -0
- novacode_cli/prompts/response_style.jinja +5 -0
- novacode_cli/prompts/session_summarization.jinja +86 -0
- novacode_cli/prompts/skill_creation.jinja +85 -0
- novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
- novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
- novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
- novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
- novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
- novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
- novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
- novacode_cli/prompts/summarization.jinja +107 -0
- novacode_cli/prompts/summarization_answers.jinja +23 -0
- novacode_cli/prompts/summarization_questions.jinja +32 -0
- novacode_cli/prompts/vision_caption.jinja +21 -0
- novacode_cli/prompts/voice_summary.jinja +65 -0
- novacode_cli/prompts/wiki_file.jinja +57 -0
- novacode_cli/prompts/wiki_ingest.jinja +87 -0
- novacode_cli/recovery.py +383 -0
- novacode_cli/remote/__init__.py +45 -0
- novacode_cli/remote/approvals.py +282 -0
- novacode_cli/remote/async_task_clients.py +97 -0
- novacode_cli/remote/async_task_watcher.py +240 -0
- novacode_cli/remote/bridge.py +905 -0
- novacode_cli/remote/command_menu.py +26 -0
- novacode_cli/remote/command_ui.py +243 -0
- novacode_cli/remote/config.py +199 -0
- novacode_cli/remote/discord_bridge.py +530 -0
- novacode_cli/remote/discord_format.py +31 -0
- novacode_cli/remote/discord_threads.py +225 -0
- novacode_cli/remote/images.py +97 -0
- novacode_cli/remote/processor.py +601 -0
- novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
- novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
- novacode_cli/remote/rootapp_bot/package.json +18 -0
- novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
- novacode_cli/remote/rootapp_bridge.py +370 -0
- novacode_cli/remote/rootapp_setup.py +89 -0
- novacode_cli/remote/routing.py +103 -0
- novacode_cli/remote/scheduler.py +302 -0
- novacode_cli/remote/session.py +164 -0
- novacode_cli/remote/status.py +411 -0
- novacode_cli/remote/streaming.py +188 -0
- novacode_cli/remote/telegram_bridge.py +1118 -0
- novacode_cli/remote/telegram_format.py +215 -0
- novacode_cli/remote/telegram_hub.py +256 -0
- novacode_cli/remote/voice_notes.py +120 -0
- novacode_cli/remote/webhook_adapters.py +167 -0
- novacode_cli/remote/webhook_server.py +154 -0
- novacode_cli/rewind.py +150 -0
- novacode_cli/rooms/__init__.py +1 -0
- novacode_cli/rooms/bridge.py +86 -0
- novacode_cli/rooms/cli.py +231 -0
- novacode_cli/rooms/nova.py +227 -0
- novacode_cli/rooms/runtime.py +299 -0
- novacode_cli/rooms/store.py +538 -0
- novacode_cli/rooms/workspace.py +94 -0
- novacode_cli/security/__init__.py +29 -0
- novacode_cli/security/approval_context.py +45 -0
- novacode_cli/security/delegated_approval.py +68 -0
- novacode_cli/security/local_http.py +85 -0
- novacode_cli/security/middleware.py +87 -0
- novacode_cli/security/paths.py +35 -0
- novacode_cli/security/policy.py +535 -0
- novacode_cli/security/policy_writer.py +90 -0
- novacode_cli/security/prompt_injection.py +203 -0
- novacode_cli/security/remember.py +81 -0
- novacode_cli/security/rule_synthesis.py +116 -0
- novacode_cli/security/secret_files.py +61 -0
- novacode_cli/security/session_allow.py +114 -0
- novacode_cli/security/unicode_security.py +516 -0
- novacode_cli/security/validator.py +173 -0
- novacode_cli/server/__init__.py +9 -0
- novacode_cli/server/app.py +632 -0
- novacode_cli/server/event_adapter.py +189 -0
- novacode_cli/server/session_manager.py +90 -0
- novacode_cli/server_main.py +64 -0
- novacode_cli/server_runner/dev_server.py +530 -0
- novacode_cli/server_runner/test_runner.py +470 -0
- novacode_cli/session/__init__.py +1 -0
- novacode_cli/session/adapters/__init__.py +240 -0
- novacode_cli/session/adapters/claude_code.py +102 -0
- novacode_cli/session/adapters/codex.py +77 -0
- novacode_cli/session/adapters/harness_native.py +75 -0
- novacode_cli/session/checkpoint_coordinator.py +448 -0
- novacode_cli/session/context_restore.py +25 -0
- novacode_cli/session/history_export.py +228 -0
- novacode_cli/session/history_tree.py +174 -0
- novacode_cli/session/imported_context.py +267 -0
- novacode_cli/session/runtime_identity.py +32 -0
- novacode_cli/session/session_persistence.py +1168 -0
- novacode_cli/session/session_prompt_builder.py +279 -0
- novacode_cli/session/session_restore.py +351 -0
- novacode_cli/session/session_summarization.py +20 -0
- novacode_cli/session/transcript_journal.py +153 -0
- novacode_cli/sessions/__init__.py +11 -0
- novacode_cli/sessions/gateway.py +84 -0
- novacode_cli/sessions/launch.py +51 -0
- novacode_cli/sessions/lease.py +213 -0
- novacode_cli/sessions/messaging.py +255 -0
- novacode_cli/sessions/protocol.py +216 -0
- novacode_cli/sessions/resume.py +31 -0
- novacode_cli/sessions/supervisor.py +498 -0
- novacode_cli/sessions/tasks.py +73 -0
- novacode_cli/sessions/tui_messaging.py +413 -0
- novacode_cli/sessions/worker.py +652 -0
- novacode_cli/sessions/worktree.py +262 -0
- novacode_cli/shell/__init__.py +27 -0
- novacode_cli/shell/jobs.py +475 -0
- novacode_cli/shell/middleware.py +1568 -0
- novacode_cli/shell/os_sandbox.py +205 -0
- novacode_cli/shell/output.py +181 -0
- novacode_cli/shell/patterns.py +275 -0
- novacode_cli/shell/processes.py +87 -0
- novacode_cli/shell/utils.py +236 -0
- novacode_cli/skills/__init__.py +26 -0
- novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
- novacode_cli/skills/curation_middleware.py +69 -0
- novacode_cli/skills/executable.py +161 -0
- novacode_cli/skills/libraries.py +102 -0
- novacode_cli/skills/load.py +248 -0
- novacode_cli/skills/prune.py +215 -0
- novacode_cli/skills/refreshing_middleware.py +896 -0
- novacode_cli/skills/retrieval.py +248 -0
- novacode_cli/skills/runtime.py +118 -0
- novacode_cli/skills/schema.py +254 -0
- novacode_cli/skills/skill_creation.py +1549 -0
- novacode_cli/skills/skill_lock.py +75 -0
- novacode_cli/skills/skill_system_prompt.py +16 -0
- novacode_cli/skills/skills_prefs.py +126 -0
- novacode_cli/skills/tool_resolver.py +31 -0
- novacode_cli/skills/upstream.py +50 -0
- novacode_cli/skills/versioning.py +291 -0
- novacode_cli/states/Session.py +847 -0
- novacode_cli/states/__init__.py +31 -0
- novacode_cli/states/slices/__init__.py +17 -0
- novacode_cli/states/slices/agent_runtime.py +249 -0
- novacode_cli/states/slices/background_tasks.py +21 -0
- novacode_cli/states/slices/notifications.py +74 -0
- novacode_cli/states/slices/remote_bridge.py +56 -0
- novacode_cli/states/slices/ui_settings.py +49 -0
- novacode_cli/states/slices/wiki.py +25 -0
- novacode_cli/teammates/__init__.py +1 -0
- novacode_cli/teammates/controller.py +557 -0
- novacode_cli/teammates/graph.py +104 -0
- novacode_cli/teammates/registry.py +175 -0
- novacode_cli/teammates/service.py +254 -0
- novacode_cli/teammates/worker.py +19 -0
- novacode_cli/teams/__init__.py +1 -0
- novacode_cli/teams/adapters/__init__.py +1 -0
- novacode_cli/teams/adapters/container.py +316 -0
- novacode_cli/teams/adapters/inprocess.py +206 -0
- novacode_cli/teams/adapters/process.py +364 -0
- novacode_cli/teams/bootstrap.py +246 -0
- novacode_cli/teams/container_git.py +67 -0
- novacode_cli/teams/context.py +34 -0
- novacode_cli/teams/coordinator.py +815 -0
- novacode_cli/teams/gateway.py +200 -0
- novacode_cli/teams/host.py +64 -0
- novacode_cli/teams/legacy.py +52 -0
- novacode_cli/teams/middleware.py +176 -0
- novacode_cli/teams/models.py +104 -0
- novacode_cli/teams/output.py +47 -0
- novacode_cli/teams/protocol.py +50 -0
- novacode_cli/teams/recovery.py +80 -0
- novacode_cli/teams/scheduler.py +44 -0
- novacode_cli/teams/store.py +631 -0
- novacode_cli/teams/worker.py +251 -0
- novacode_cli/teams/workspace.py +100 -0
- novacode_cli/token_utils.py +256 -0
- novacode_cli/tools/__init__.py +104 -0
- novacode_cli/tools/_shared.py +97 -0
- novacode_cli/tools/_types.py +24 -0
- novacode_cli/tools/artifact_tools.py +137 -0
- novacode_cli/tools/code_search_tools.py +330 -0
- novacode_cli/tools/computer_tools.py +77 -0
- novacode_cli/tools/cron_tools.py +65 -0
- novacode_cli/tools/daemon_tool.py +290 -0
- novacode_cli/tools/desktop_tools.py +161 -0
- novacode_cli/tools/fetch_tools.py +530 -0
- novacode_cli/tools/http_tools.py +279 -0
- novacode_cli/tools/inspection_tools.py +107 -0
- novacode_cli/tools/job_tools.py +143 -0
- novacode_cli/tools/knowledge_tools.py +427 -0
- novacode_cli/tools/memory_tools.py +461 -0
- novacode_cli/tools/memory_wiki_tools.py +40 -0
- novacode_cli/tools/notebook_tools.py +238 -0
- novacode_cli/tools/oracle_tool.py +83 -0
- novacode_cli/tools/plan_archive_tools.py +80 -0
- novacode_cli/tools/plan_mode_tools.py +315 -0
- novacode_cli/tools/python_kernel_loop.py +165 -0
- novacode_cli/tools/python_kernel_tool.py +222 -0
- novacode_cli/tools/reflection_tools.py +34 -0
- novacode_cli/tools/scraper_tools.py +377 -0
- novacode_cli/tools/skill_tools.py +275 -0
- novacode_cli/tools/speak_tool.py +51 -0
- novacode_cli/tools/store_memory_tools.py +227 -0
- novacode_cli/tools/ui_tools.py +62 -0
- novacode_cli/tools/waiter_tools.py +408 -0
- novacode_cli/tools/web_tools.py +349 -0
- novacode_cli/tools/wiki_tools.py +152 -0
- novacode_cli/tools/worktree_tools.py +219 -0
- novacode_cli/tracking/__init__.py +10 -0
- novacode_cli/tracking/file_tracker.py +826 -0
- novacode_cli/tracking/local_metrics.py +170 -0
- novacode_cli/tracking/loop_guard.py +507 -0
- novacode_cli/tracking/request_metrics.py +60 -0
- novacode_cli/tracking/tracing.py +692 -0
- novacode_cli/tracking/usage_tree.py +677 -0
- novacode_cli/tracking/workspace_anchoring.py +231 -0
- novacode_cli/tui/__init__.py +11 -0
- novacode_cli/tui/animation_rate.py +9 -0
- novacode_cli/tui/animations.py +332 -0
- novacode_cli/tui/app.py +19315 -0
- novacode_cli/tui/auth_display.py +87 -0
- novacode_cli/tui/auth_screens.py +826 -0
- novacode_cli/tui/avatar.py +294 -0
- novacode_cli/tui/avatar_frames.py +21 -0
- novacode_cli/tui/background_tasks.py +35 -0
- novacode_cli/tui/browser_settings_screen.py +70 -0
- novacode_cli/tui/caveman_screen.py +185 -0
- novacode_cli/tui/council_settings_screen.py +182 -0
- novacode_cli/tui/council_view.py +535 -0
- novacode_cli/tui/decision_settings.py +268 -0
- novacode_cli/tui/diff_preview.py +199 -0
- novacode_cli/tui/gc_tuning.py +63 -0
- novacode_cli/tui/handoff_screen.py +206 -0
- novacode_cli/tui/harness.py +244 -0
- novacode_cli/tui/history_tree_screen.py +179 -0
- novacode_cli/tui/home.py +328 -0
- novacode_cli/tui/home_scene.py +94 -0
- novacode_cli/tui/icons.py +81 -0
- novacode_cli/tui/interface_styles.tcss +327 -0
- novacode_cli/tui/matrix3.py +108 -0
- novacode_cli/tui/mcp_tools_screen.py +131 -0
- novacode_cli/tui/motion.py +178 -0
- novacode_cli/tui/output_body.py +136 -0
- novacode_cli/tui/output_buffer.py +59 -0
- novacode_cli/tui/palette.py +334 -0
- novacode_cli/tui/pickers.py +237 -0
- novacode_cli/tui/prime_panel.py +506 -0
- novacode_cli/tui/provider_signin.py +213 -0
- novacode_cli/tui/pty_shell.py +159 -0
- novacode_cli/tui/reference_screens.py +444 -0
- novacode_cli/tui/resume_picker.py +346 -0
- novacode_cli/tui/rooms.py +597 -0
- novacode_cli/tui/screens.py +7088 -0
- novacode_cli/tui/session_import.py +333 -0
- novacode_cli/tui/session_pane.py +242 -0
- novacode_cli/tui/session_tab.py +42 -0
- novacode_cli/tui/session_transcript.py +118 -0
- novacode_cli/tui/shell_output.py +7 -0
- novacode_cli/tui/stall_watch.py +174 -0
- novacode_cli/tui/statusline.py +40 -0
- novacode_cli/tui/statusline_screen.py +91 -0
- novacode_cli/tui/teammate_updates.py +162 -0
- novacode_cli/tui/teammates.py +548 -0
- novacode_cli/tui/terminal.py +289 -0
- novacode_cli/tui/terminal_view.py +235 -0
- novacode_cli/tui/themes.py +118 -0
- novacode_cli/tui/update_notice.py +84 -0
- novacode_cli/tui/voice_downloads.py +113 -0
- novacode_cli/tui/widgets.py +2038 -0
- novacode_cli/tui/windows_input.py +38 -0
- novacode_cli/tui/workspace_approval.py +165 -0
- novacode_cli/ui/council_web.py +113 -0
- novacode_cli/ui/council_workspace.py +72 -0
- novacode_cli/ui/cowork_web.py +41 -0
- novacode_cli/ui/diff_highlight.py +160 -0
- novacode_cli/ui/execution.py +1396 -0
- novacode_cli/ui/exit_summary.py +65 -0
- novacode_cli/ui/hitl_approval.py +196 -0
- novacode_cli/ui/input_preparation.py +2 -0
- novacode_cli/ui/interrupt_handlers.py +215 -0
- novacode_cli/ui/question_prompt.py +151 -0
- novacode_cli/ui/status_phrases.py +157 -0
- novacode_cli/ui/stream_preview.py +66 -0
- novacode_cli/ui/streaming.py +2 -0
- novacode_cli/ui/subagent_tracking.py +2 -0
- novacode_cli/ui/ui_elements.py +1585 -0
- novacode_cli/ui/web_workspace.py +244 -0
- novacode_cli/ui_events.py +299 -0
- novacode_cli/update_ui.py +243 -0
- novacode_cli/updates.py +489 -0
- novacode_cli/utils/backend_patches.py +399 -0
- novacode_cli/utils/backend_paths.py +467 -0
- novacode_cli/utils/lazy_providers.py +51 -0
- novacode_cli/utils/model_info.py +223 -0
- novacode_cli/utils/pdf_extraction.py +183 -0
- novacode_cli/utils/startup.py +29 -0
- novacode_cli/utils/tasks.py +42 -0
- novacode_cli/utils/windows.py +9 -0
- novacode_cli/vixie/hook_adapter.py +127 -0
- novacode_cli/vixie/server.py +375 -0
- novacode_cli/wiki/__init__.py +15 -0
- novacode_cli/wiki/ask.py +56 -0
- novacode_cli/wiki/file.py +71 -0
- novacode_cli/wiki/ingest.py +184 -0
- novacode_cli/wiki/manager.py +261 -0
- novacode_cli-1.2.0.dist-info/METADATA +271 -0
- novacode_cli-1.2.0.dist-info/RECORD +691 -0
- novacode_cli-1.2.0.dist-info/WHEEL +5 -0
- novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
- novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
- novacode_cli-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1821 @@
|
|
|
1
|
+
"""Shared LangGraph agent iteration loop.
|
|
2
|
+
|
|
3
|
+
``iterate_agent_events`` is the single canonical async generator for driving a
|
|
4
|
+
LangGraph agent stream. It yields :mod:`novacode_cli.ui_events` dataclass
|
|
5
|
+
instances that describe what happened — text, tool calls, interrupts, etc. —
|
|
6
|
+
without coupling to any particular rendering strategy.
|
|
7
|
+
|
|
8
|
+
Two consumers wrap it:
|
|
9
|
+
|
|
10
|
+
* :func:`novacode_cli.agent_stream.run_agent_stream` — forwards events directly
|
|
11
|
+
(for the Textual TUI and test code).
|
|
12
|
+
* :func:`novacode_cli.ui.execution.execute_task` — consumes events and renders
|
|
13
|
+
each to the ``rich`` console.
|
|
14
|
+
|
|
15
|
+
Bug fixes and feature additions belong *here* so both UIs benefit immediately.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import AsyncIterator
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from langchain.agents.middleware.human_in_the_loop import HITLRequest
|
|
27
|
+
from langchain_core.messages import HumanMessage, ToolMessage
|
|
28
|
+
from langgraph.errors import GraphInterrupt
|
|
29
|
+
from langgraph.types import Command, Interrupt
|
|
30
|
+
from pydantic import TypeAdapter, ValidationError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
from novacode_cli.config.config import COLORS, get_agent_color
|
|
34
|
+
from novacode_cli.file_ops import get_session_file_op_tracker
|
|
35
|
+
from novacode_cli.core.reasoning import reasoning_text
|
|
36
|
+
from novacode_cli.core.input_preparation import (
|
|
37
|
+
build_agent_config,
|
|
38
|
+
get_agent_display_name,
|
|
39
|
+
prepare_input_content,
|
|
40
|
+
)
|
|
41
|
+
from novacode_cli.core.streaming import (
|
|
42
|
+
TOOL_CATEGORIES,
|
|
43
|
+
TOOL_ICONS,
|
|
44
|
+
_could_be_session_intent_heading,
|
|
45
|
+
format_condensed_activity,
|
|
46
|
+
is_internal_context_text,
|
|
47
|
+
looks_like_summarization_output,
|
|
48
|
+
)
|
|
49
|
+
from novacode_cli.tracking.loop_guard import TextRepetitionGuard
|
|
50
|
+
from novacode_cli.tracking.tracing import traced_stream
|
|
51
|
+
from novacode_cli.tracking.usage_tree import scoped_stream
|
|
52
|
+
from novacode_cli.ui import status_phrases
|
|
53
|
+
from novacode_cli.core.subagent_tracking import (
|
|
54
|
+
SubagentTracker,
|
|
55
|
+
get_status_icon,
|
|
56
|
+
)
|
|
57
|
+
from novacode_cli.core.subagent_tasks import (
|
|
58
|
+
TASK_ID_METADATA_KEY,
|
|
59
|
+
task_from_async_entry,
|
|
60
|
+
task_from_custom_event,
|
|
61
|
+
task_from_sync_completion,
|
|
62
|
+
task_from_sync_dispatch,
|
|
63
|
+
)
|
|
64
|
+
from novacode_cli.ui.ui_elements import (
|
|
65
|
+
format_tool_display,
|
|
66
|
+
format_tool_message_content,
|
|
67
|
+
format_tool_result_preview,
|
|
68
|
+
)
|
|
69
|
+
from novacode_cli import ui_events as ev
|
|
70
|
+
|
|
71
|
+
if TYPE_CHECKING:
|
|
72
|
+
from collections.abc import AsyncGenerator
|
|
73
|
+
|
|
74
|
+
_HITL_REQUEST_ADAPTER = TypeAdapter(HITLRequest)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _RepetitionLoop(Exception):
|
|
78
|
+
"""Raised to abort a turn whose model output has started cycling."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
#: Consecutive policy-rejected resumes (no human asked) before the turn is
|
|
82
|
+
#: aborted. Each resume is a fresh model call, so this bounds a runaway loop.
|
|
83
|
+
MAX_AUTO_REJECT_RESUMES = 3
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def format_interrupt_notification(kind: str, payload: Any) -> str:
|
|
87
|
+
"""Turn a raw interrupt payload into a short, human-readable line for
|
|
88
|
+
notifications (desktop toast, badge, remote) — instead of a dumped dict.
|
|
89
|
+
|
|
90
|
+
tool → ``"shell: docker ps --format …"`` (one line per action request);
|
|
91
|
+
plan/question → a friendly sentence.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
if kind == "tool":
|
|
95
|
+
reqs = (payload or {}).get("action_requests") or []
|
|
96
|
+
lines: list[str] = []
|
|
97
|
+
for r in reqs:
|
|
98
|
+
name = r.get("name") or "tool"
|
|
99
|
+
args = r.get("args") or {}
|
|
100
|
+
detail = (
|
|
101
|
+
args.get("command")
|
|
102
|
+
or args.get("file_path")
|
|
103
|
+
or args.get("path")
|
|
104
|
+
or args.get("url")
|
|
105
|
+
or r.get("description")
|
|
106
|
+
)
|
|
107
|
+
if not detail and args:
|
|
108
|
+
detail = ", ".join(f"{k}={v}" for k, v in list(args.items())[:3])
|
|
109
|
+
detail = " ".join(str(detail).split()) if detail else "" # collapse whitespace/newlines
|
|
110
|
+
lines.append(f"{name}: {detail}" if detail else str(name))
|
|
111
|
+
msg = " • ".join(lines) or "A tool action needs your approval."
|
|
112
|
+
elif kind == "plan":
|
|
113
|
+
msg = "A plan is ready for your review and approval."
|
|
114
|
+
elif kind == "question":
|
|
115
|
+
q = (payload or {}).get("question") or (payload or {}).get("prompt") or ""
|
|
116
|
+
msg = " ".join(str(q).split()) or "The agent is asking a question."
|
|
117
|
+
else:
|
|
118
|
+
msg = "Nova needs your input."
|
|
119
|
+
except Exception: # noqa: BLE001 — a formatter must never break the turn
|
|
120
|
+
msg = "Nova needs your approval."
|
|
121
|
+
return msg[:220]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def default_interrupt_response(kind: str) -> Any:
|
|
125
|
+
"""Safe fallback for an unresolved :class:`~novacode_cli.ui_events.InterruptRequest`.
|
|
126
|
+
|
|
127
|
+
Consumers resolve the request's future in their own coroutines; if that
|
|
128
|
+
handler raises before calling ``set_result``, the agent loop would await the
|
|
129
|
+
future forever. Consumers should resolve with this value in a ``finally`` so
|
|
130
|
+
the turn fails closed (reject) instead of hanging.
|
|
131
|
+
"""
|
|
132
|
+
if kind == "tool":
|
|
133
|
+
return {"decisions": [], "any_rejected": True}
|
|
134
|
+
if kind == "plan":
|
|
135
|
+
return {
|
|
136
|
+
"response": {"approved": False, "action": "reject", "feedback": ""},
|
|
137
|
+
"state_update": {},
|
|
138
|
+
}
|
|
139
|
+
# "question" and anything else: an empty response is benign.
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
#: Written into the conversation when the user cancels a turn.
|
|
144
|
+
_CANCEL_NOTICE = "[The previous request was interrupted before it finished]"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _record_cancellation(agent: Any, config: Any) -> None:
|
|
148
|
+
"""Record a cancelled turn in the agent's history, leaving it well-formed.
|
|
149
|
+
|
|
150
|
+
Cancelling while a tool runs is the common case: the model's tool-calling
|
|
151
|
+
message is already committed, but the tool never reports back. Appending
|
|
152
|
+
the cancel notice straight after it left an assistant message whose
|
|
153
|
+
``tool_calls`` were followed by a *user* message — which OpenAI-strict
|
|
154
|
+
endpoints reject ("An assistant message with 'tool_calls' must be followed
|
|
155
|
+
by tool messages responding to each 'tool_call_id'"), killing every later
|
|
156
|
+
turn in that conversation until something repaired it.
|
|
157
|
+
|
|
158
|
+
Found in real history: of 514 saved conversations, 5 broke that rule, and
|
|
159
|
+
4 of the 5 were followed by exactly this notice. So each unanswered call
|
|
160
|
+
now gets an honest "cancelled" result first, then the notice.
|
|
161
|
+
|
|
162
|
+
Only the final message is patched: that is precisely the cancel-mid-tool
|
|
163
|
+
shape, and appending directly after it keeps each result adjacent to its
|
|
164
|
+
call. A dangling call anywhere else is left to RepairToolCallsEachStep,
|
|
165
|
+
which rewrites in place rather than appending.
|
|
166
|
+
"""
|
|
167
|
+
from langchain_core.messages import AIMessage
|
|
168
|
+
|
|
169
|
+
messages: list[Any] = [HumanMessage(content=_CANCEL_NOTICE)]
|
|
170
|
+
try:
|
|
171
|
+
snapshot = await agent.aget_state(config)
|
|
172
|
+
history = (getattr(snapshot, "values", None) or {}).get("messages", []) or []
|
|
173
|
+
except Exception: # noqa: BLE001 — recording the notice must not depend on this
|
|
174
|
+
history = []
|
|
175
|
+
|
|
176
|
+
last = history[-1] if history else None
|
|
177
|
+
if isinstance(last, AIMessage) and last.tool_calls:
|
|
178
|
+
answered = {
|
|
179
|
+
getattr(m, "tool_call_id", None) for m in history if getattr(m, "type", "") == "tool"
|
|
180
|
+
}
|
|
181
|
+
results = [
|
|
182
|
+
ToolMessage(
|
|
183
|
+
content="Cancelled by the user before it finished; no result.",
|
|
184
|
+
tool_call_id=call["id"],
|
|
185
|
+
name=call.get("name") or "unknown",
|
|
186
|
+
)
|
|
187
|
+
for call in last.tool_calls
|
|
188
|
+
if call.get("id") and call["id"] not in answered
|
|
189
|
+
]
|
|
190
|
+
messages = [*results, *messages]
|
|
191
|
+
|
|
192
|
+
await agent.aupdate_state(config=config, values={"messages": messages}, as_node="model")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _safe_stream(stream_gen: AsyncIterator[Any]) -> AsyncIterator[Any]:
|
|
196
|
+
"""Wraps an async generator and swallows GraphInterrupt.
|
|
197
|
+
|
|
198
|
+
This ensures that when the graph execution is suspended due to an interrupt,
|
|
199
|
+
the resulting GraphInterrupt exception is caught and the stream completes
|
|
200
|
+
normally instead of crashing the iteration loop.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
async for chunk in stream_gen:
|
|
204
|
+
yield chunk
|
|
205
|
+
except GraphInterrupt:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _save_unresumed_plan(approved_plan: tuple[str, bool] | None, session_state: Any) -> str | None:
|
|
210
|
+
"""Persist an approved plan whose planner will not be resumed. Best-effort.
|
|
211
|
+
|
|
212
|
+
User-approved → the project's ``.nova/plans/``; approved by Nova itself →
|
|
213
|
+
the global ``~/.nova/plans/``. A planner that passed no inline plan wrote
|
|
214
|
+
its own file under ``.nova/plans/`` (the one place plan mode lets it
|
|
215
|
+
write): that is already the right home for a user-approved plan, and is
|
|
216
|
+
read back so an auto-approved one still reaches the global folder.
|
|
217
|
+
"""
|
|
218
|
+
if approved_plan is None:
|
|
219
|
+
return None
|
|
220
|
+
text, auto = approved_plan
|
|
221
|
+
try:
|
|
222
|
+
if not text.strip():
|
|
223
|
+
if not auto:
|
|
224
|
+
return None
|
|
225
|
+
from novacode_cli.ui.interrupt_handlers import resolve_plan_content
|
|
226
|
+
|
|
227
|
+
text = resolve_plan_content(None, session_state)[0] or ""
|
|
228
|
+
from novacode_cli.tools.plan_mode_tools import persist_approved_plan
|
|
229
|
+
|
|
230
|
+
return persist_approved_plan(text, auto_approved=auto)
|
|
231
|
+
except Exception: # noqa: BLE001 — saving a plan must never break the turn
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def iterate_agent_events(
|
|
236
|
+
user_input: str,
|
|
237
|
+
agent: Any, # noqa: ANN401 - frontend agents include compiled and wrapped graphs.
|
|
238
|
+
assistant_id: str | None,
|
|
239
|
+
session_state: Any, # noqa: ANN401 - TUI, headless, and remote session adapters.
|
|
240
|
+
*,
|
|
241
|
+
backend: Any = None, # noqa: ANN401 - forwards the existing backend interface.
|
|
242
|
+
image_tracker: Any = None, # noqa: ANN401 - forwards the existing tracker interface.
|
|
243
|
+
seen_message_ids: set[str] | None = None,
|
|
244
|
+
skip_file_mentions: bool = False,
|
|
245
|
+
) -> AsyncGenerator[Any, None]:
|
|
246
|
+
"""Drive one turn with usage attribution shared by every frontend."""
|
|
247
|
+
from novacode_cli.prime_factory.runtime import is_factory_request, iterate_factory_events
|
|
248
|
+
|
|
249
|
+
if is_factory_request(user_input, session_state):
|
|
250
|
+
async for event in iterate_factory_events(user_input, session_state, agent=agent):
|
|
251
|
+
yield event
|
|
252
|
+
return
|
|
253
|
+
from novacode_cli.tracking.usage_tree import (
|
|
254
|
+
UsageTree,
|
|
255
|
+
_current_tree,
|
|
256
|
+
get_current_tree,
|
|
257
|
+
persist_usage_tree,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
previous = get_current_tree()
|
|
261
|
+
tree = previous if previous is not None else UsageTree(session_state.thread_id)
|
|
262
|
+
stream = _iterate_agent_events(
|
|
263
|
+
user_input,
|
|
264
|
+
agent,
|
|
265
|
+
assistant_id,
|
|
266
|
+
session_state,
|
|
267
|
+
backend=backend,
|
|
268
|
+
image_tracker=image_tracker,
|
|
269
|
+
seen_message_ids=seen_message_ids,
|
|
270
|
+
skip_file_mentions=skip_file_mentions,
|
|
271
|
+
legacy_prime=False,
|
|
272
|
+
)
|
|
273
|
+
try:
|
|
274
|
+
while True:
|
|
275
|
+
# A consumer may close this generator from a different task. Bind
|
|
276
|
+
# only while advancing it, and restore the caller before yielding.
|
|
277
|
+
token = _current_tree.set(tree)
|
|
278
|
+
try:
|
|
279
|
+
event = await anext(stream)
|
|
280
|
+
except StopAsyncIteration:
|
|
281
|
+
break
|
|
282
|
+
finally:
|
|
283
|
+
_current_tree.reset(token)
|
|
284
|
+
yield event
|
|
285
|
+
finally:
|
|
286
|
+
token = _current_tree.set(tree)
|
|
287
|
+
try:
|
|
288
|
+
await stream.aclose()
|
|
289
|
+
finally:
|
|
290
|
+
_current_tree.reset(token)
|
|
291
|
+
if previous is None and tree.root.totals()["calls"]:
|
|
292
|
+
await persist_usage_tree(tree)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def _iterate_agent_events( # noqa: C901, PLR0912, PLR0915
|
|
296
|
+
user_input: str,
|
|
297
|
+
agent,
|
|
298
|
+
assistant_id: str | None,
|
|
299
|
+
session_state,
|
|
300
|
+
*,
|
|
301
|
+
backend=None,
|
|
302
|
+
image_tracker=None,
|
|
303
|
+
seen_message_ids: set[str] | None = None,
|
|
304
|
+
skip_file_mentions: bool = False,
|
|
305
|
+
legacy_prime: bool = True,
|
|
306
|
+
) -> AsyncGenerator[Any, None]:
|
|
307
|
+
"""Run the agent and yield UI events.
|
|
308
|
+
|
|
309
|
+
Yields instances from :mod:`novacode_cli.ui_events`. Terminates with a
|
|
310
|
+
:class:`~novacode_cli.ui_events.Done`, :class:`~novacode_cli.ui_events.Cancelled`,
|
|
311
|
+
or :class:`~novacode_cli.ui_events.Error` event.
|
|
312
|
+
"""
|
|
313
|
+
from novacode_cli.agents.ponytail import helper_prompt, is_deactivation_command
|
|
314
|
+
from novacode_cli.config.nova_config import NovaConfig
|
|
315
|
+
|
|
316
|
+
compression_config = await asyncio.to_thread(NovaConfig)
|
|
317
|
+
if (
|
|
318
|
+
compression_config.get_compression_mode() == "ponytail"
|
|
319
|
+
and is_deactivation_command(user_input)
|
|
320
|
+
):
|
|
321
|
+
try:
|
|
322
|
+
await asyncio.to_thread(compression_config.set_compression_mode, "off")
|
|
323
|
+
except OSError:
|
|
324
|
+
yield ev.Error(message="Could not save compression preference.")
|
|
325
|
+
yield ev.Done(had_response=False)
|
|
326
|
+
return
|
|
327
|
+
command = user_input.split(maxsplit=1)
|
|
328
|
+
if command and command[0].lower() in {
|
|
329
|
+
"/ponytail-" + name for name in ("review", "audit", "debt", "gain", "help")
|
|
330
|
+
}:
|
|
331
|
+
user_input = helper_prompt(user_input)
|
|
332
|
+
loop = asyncio.get_running_loop()
|
|
333
|
+
prime_original_input = user_input
|
|
334
|
+
|
|
335
|
+
if getattr(session_state, "plan_mode_enabled", False):
|
|
336
|
+
directive = (
|
|
337
|
+
"[System Directive: You are entering a new planning phase. "
|
|
338
|
+
"Any previous plan approvals have expired. You are currently restricted to "
|
|
339
|
+
"read-only tools. Design a new plan for the request below, and call `exit_plan_mode(plan=...)` "
|
|
340
|
+
"to request user approval. Do NOT call write_todos or attempt to implement yet.]\n\n"
|
|
341
|
+
)
|
|
342
|
+
if not user_input.startswith("[System Directive:"):
|
|
343
|
+
user_input = directive + user_input
|
|
344
|
+
|
|
345
|
+
active_goal = getattr(session_state, "active_goal", None)
|
|
346
|
+
goal_in_scope = (
|
|
347
|
+
bool(active_goal)
|
|
348
|
+
and getattr(session_state, "goal_status", "active") == "active"
|
|
349
|
+
and getattr(session_state, "goal_thread_id", session_state.thread_id)
|
|
350
|
+
== session_state.thread_id
|
|
351
|
+
)
|
|
352
|
+
if (
|
|
353
|
+
goal_in_scope
|
|
354
|
+
and not user_input.startswith("[GOAL]")
|
|
355
|
+
and not user_input.startswith("[System Directive:")
|
|
356
|
+
):
|
|
357
|
+
user_input = f"[GOAL] {active_goal}\n\n{user_input}"
|
|
358
|
+
|
|
359
|
+
message_content = await prepare_input_content(
|
|
360
|
+
user_input, image_tracker, skip_file_mentions=skip_file_mentions
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
thread_id = session_state.thread_id
|
|
364
|
+
config = build_agent_config(thread_id, assistant_id)
|
|
365
|
+
from novacode_cli.config.config import settings
|
|
366
|
+
from novacode_cli.session.runtime_identity import RuntimeIdentity
|
|
367
|
+
|
|
368
|
+
project = getattr(agent, "project", None)
|
|
369
|
+
if not isinstance(project, str):
|
|
370
|
+
project = str(settings.get_workspace_root())
|
|
371
|
+
runtime_session = RuntimeIdentity.from_config(project, config, "").session
|
|
372
|
+
team_host = getattr(agent, "team_coordinator", None)
|
|
373
|
+
if team_host is None:
|
|
374
|
+
from novacode_cli.teams.host import TeamHost
|
|
375
|
+
|
|
376
|
+
team_host = next(
|
|
377
|
+
(
|
|
378
|
+
resource
|
|
379
|
+
for resource in getattr(agent, "runtime_resources", ())
|
|
380
|
+
if isinstance(resource, TeamHost)
|
|
381
|
+
),
|
|
382
|
+
None,
|
|
383
|
+
)
|
|
384
|
+
team_runtime = team_host.bind_session(session_state) if team_host is not None else None
|
|
385
|
+
|
|
386
|
+
agent_display_name = get_agent_display_name(assistant_id)
|
|
387
|
+
agent_colors = (
|
|
388
|
+
get_agent_color(assistant_id)
|
|
389
|
+
if assistant_id and assistant_id != "nova-agent"
|
|
390
|
+
else (COLORS["success"] if assistant_id == "nova-agent" else COLORS["agent"])
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if seen_message_ids is None:
|
|
394
|
+
seen_message_ids = set()
|
|
395
|
+
seen_before_turn = set(seen_message_ids)
|
|
396
|
+
|
|
397
|
+
file_op_tracker = get_session_file_op_tracker(assistant_id=assistant_id, backend=backend)
|
|
398
|
+
subagent_tracker = SubagentTracker()
|
|
399
|
+
|
|
400
|
+
# Watches the model's own prose for a verbatim-repeat cycle — the failure
|
|
401
|
+
# LoopGuardMiddleware can't see, because no tool is ever called.
|
|
402
|
+
text_guard = TextRepetitionGuard()
|
|
403
|
+
|
|
404
|
+
displayed_tool_ids: set[str] = set()
|
|
405
|
+
tool_call_buffers: dict[str | int, dict] = {}
|
|
406
|
+
streamed_reasoning_ids: set[tuple[str, str | None]] = set()
|
|
407
|
+
tool_call_to_name: dict[str, str] = {}
|
|
408
|
+
pending_text = ""
|
|
409
|
+
current_ai_message_id: str | None = None
|
|
410
|
+
_post_summarization = False
|
|
411
|
+
compaction_status_started = False
|
|
412
|
+
summary_preview_suppressed = False
|
|
413
|
+
preview_started = False
|
|
414
|
+
has_responded = False
|
|
415
|
+
_streamed_pending = False # whether TextDelta(s) were emitted for the buffer
|
|
416
|
+
|
|
417
|
+
captured = ev.UsageUpdate()
|
|
418
|
+
context_shown = 0 # last request size sent to the UI as ContextUsage
|
|
419
|
+
usage_calls: dict[object, tuple[int, int, int, int]] = {}
|
|
420
|
+
compacted = False
|
|
421
|
+
compaction_finished = False
|
|
422
|
+
usage_after_compaction = False
|
|
423
|
+
|
|
424
|
+
# Drain any Nova events queued by the middleware (review cycles, skill
|
|
425
|
+
# activity) into proper ContextMessage events for both UIs.
|
|
426
|
+
def _drain_nova_events() -> list:
|
|
427
|
+
try:
|
|
428
|
+
from novacode_cli.events import nova_event_log
|
|
429
|
+
|
|
430
|
+
events: list[ev.ContextMessage] = []
|
|
431
|
+
for etype, icon, color, msg in nova_event_log.drain(runtime_session):
|
|
432
|
+
events.append(
|
|
433
|
+
ev.ContextMessage(message=msg, event_type=etype, icon=icon, color=color)
|
|
434
|
+
)
|
|
435
|
+
if team_runtime is not None:
|
|
436
|
+
events.extend(team_runtime.drain_events())
|
|
437
|
+
return events
|
|
438
|
+
except Exception:
|
|
439
|
+
return []
|
|
440
|
+
|
|
441
|
+
# Turn boundary: release any held phrases so this turn opens with fresh
|
|
442
|
+
# wording, then hold this one for the rest of the turn. It must be the
|
|
443
|
+
# *sticky* form: the TUI rebuilds its status on every streamed token behind
|
|
444
|
+
# an `if self._activity != <phrase>` guard, and a phrase that re-rolled per
|
|
445
|
+
# call would defeat that guard. Both UIs consume this one message, so a
|
|
446
|
+
# reset here is what gives the console REPL per-turn variety too.
|
|
447
|
+
status_phrases.reset()
|
|
448
|
+
yield ev.StatusUpdate(
|
|
449
|
+
status_phrases.status_line("thinking", agent_display_name, sticky_phrase=True)
|
|
450
|
+
)
|
|
451
|
+
for _ctx in _drain_nova_events():
|
|
452
|
+
yield _ctx
|
|
453
|
+
|
|
454
|
+
def _flush_events() -> list:
|
|
455
|
+
"""Finalize buffered prose into events.
|
|
456
|
+
|
|
457
|
+
Returns ``[AssistantMessage]`` to commit, ``[TextDiscard]`` to drop a
|
|
458
|
+
live preview that was suppressed (internal context / dedup), or ``[]``.
|
|
459
|
+
A suppressed library-summarization block also appends a
|
|
460
|
+
``ContextMessage`` so the retraction is not silent.
|
|
461
|
+
"""
|
|
462
|
+
nonlocal pending_text, current_ai_message_id, _post_summarization
|
|
463
|
+
nonlocal has_responded, _streamed_pending
|
|
464
|
+
nonlocal summary_preview_suppressed, preview_started, compaction_status_started
|
|
465
|
+
|
|
466
|
+
def _discard(notice: str | None = None) -> list:
|
|
467
|
+
nonlocal pending_text, current_ai_message_id, _streamed_pending
|
|
468
|
+
pending_text = ""
|
|
469
|
+
current_ai_message_id = None
|
|
470
|
+
had = _streamed_pending
|
|
471
|
+
_streamed_pending = False
|
|
472
|
+
summary_preview_suppressed = False
|
|
473
|
+
preview_started = False
|
|
474
|
+
out: list = [ev.TextDiscard()] if had else []
|
|
475
|
+
if notice:
|
|
476
|
+
out.append(
|
|
477
|
+
ev.ContextMessage(
|
|
478
|
+
message=notice,
|
|
479
|
+
event_type="nova_auto_compact",
|
|
480
|
+
icon="✓",
|
|
481
|
+
color="dim",
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
return out
|
|
485
|
+
|
|
486
|
+
if not pending_text.strip():
|
|
487
|
+
return _discard()
|
|
488
|
+
|
|
489
|
+
# A summarization block is recognizable on its own (it carries a
|
|
490
|
+
# "Session Intent" section), so key off the TEXT rather than the
|
|
491
|
+
# _post_summarization flag: that flag is only set from a main-agent
|
|
492
|
+
# `updates` chunk, which does not always arrive before the summary
|
|
493
|
+
# streams — and when it didn't, the block was announced as a plain
|
|
494
|
+
# discard or shown outright.
|
|
495
|
+
if looks_like_summarization_output(pending_text):
|
|
496
|
+
_post_summarization = False
|
|
497
|
+
# Fallback for providers that stream the summary before the graph's
|
|
498
|
+
# state update. The TUI holds the heading prefix, so it can replace
|
|
499
|
+
# the hidden internal block with the compaction animation.
|
|
500
|
+
out = _discard()
|
|
501
|
+
if not compaction_status_started:
|
|
502
|
+
compaction_status_started = True
|
|
503
|
+
out.append(
|
|
504
|
+
ev.ContextMessage(
|
|
505
|
+
message="Context is compacting",
|
|
506
|
+
event_type="nova_compaction_start",
|
|
507
|
+
icon="⟳",
|
|
508
|
+
color="dim",
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
return out
|
|
512
|
+
|
|
513
|
+
if _post_summarization:
|
|
514
|
+
_post_summarization = False
|
|
515
|
+
|
|
516
|
+
if is_internal_context_text(pending_text):
|
|
517
|
+
# Internal context echo — never shown to the user.
|
|
518
|
+
return _discard()
|
|
519
|
+
|
|
520
|
+
if current_ai_message_id and current_ai_message_id in seen_message_ids:
|
|
521
|
+
return _discard()
|
|
522
|
+
|
|
523
|
+
text = pending_text.rstrip()
|
|
524
|
+
msg = ev.AssistantMessage(
|
|
525
|
+
text=text, agent_name=agent_display_name, agent_color=agent_colors
|
|
526
|
+
)
|
|
527
|
+
if current_ai_message_id:
|
|
528
|
+
seen_message_ids.add(current_ai_message_id)
|
|
529
|
+
current_ai_message_id = None
|
|
530
|
+
pending_text = ""
|
|
531
|
+
summary_preview_suppressed = False
|
|
532
|
+
preview_started = False
|
|
533
|
+
has_responded = True
|
|
534
|
+
_streamed_pending = False
|
|
535
|
+
return [msg]
|
|
536
|
+
|
|
537
|
+
# Seed dedup set with pre-existing messages.
|
|
538
|
+
pre_stream_msg_count: int | None = None
|
|
539
|
+
# A summarization event already present before this turn is not news; only a
|
|
540
|
+
# NEW one means the library compacted during it (see compaction detection).
|
|
541
|
+
_pre_summarization_event = None
|
|
542
|
+
try:
|
|
543
|
+
_pre_state = await agent.aget_state(config)
|
|
544
|
+
_pre_msgs = _pre_state.values.get("messages", [])
|
|
545
|
+
_pre_summarization_event = _pre_state.values.get("_summarization_event")
|
|
546
|
+
pre_stream_msg_count = len(_pre_msgs)
|
|
547
|
+
for _m in _pre_msgs:
|
|
548
|
+
_mid = getattr(_m, "id", None)
|
|
549
|
+
if _mid:
|
|
550
|
+
seen_message_ids.add(_mid)
|
|
551
|
+
except Exception:
|
|
552
|
+
pass
|
|
553
|
+
|
|
554
|
+
stream_input: Any = {"messages": [{"role": "user", "content": message_content}]}
|
|
555
|
+
# Pair the active rubric (set via `/goal rubric <criteria>`) with the run so
|
|
556
|
+
# RubricMiddleware's grader holds the output to those criteria. Absent ⇒ the
|
|
557
|
+
# middleware is a no-op, so this adds nothing when no rubric is set.
|
|
558
|
+
_active_rubric = (
|
|
559
|
+
getattr(session_state, "active_rubric", None)
|
|
560
|
+
if (not active_goal or goal_in_scope)
|
|
561
|
+
and not getattr(session_state, "plan_mode_enabled", False)
|
|
562
|
+
else None
|
|
563
|
+
)
|
|
564
|
+
# The checkpointer retains omitted fields; explicitly clear a rubric when
|
|
565
|
+
# the goal pauses, completes, or is cleared so old criteria cannot leak.
|
|
566
|
+
stream_input["rubric"] = _active_rubric or ""
|
|
567
|
+
_current_stream_gen: Any = None
|
|
568
|
+
|
|
569
|
+
from novacode_cli.tools.plan_mode_tools import _auto_approve_var
|
|
570
|
+
|
|
571
|
+
auto_approve_val = bool(getattr(session_state, "auto_approve", False))
|
|
572
|
+
token = _auto_approve_var.set(auto_approve_val)
|
|
573
|
+
|
|
574
|
+
from novacode_cli.prime import PrimeRun, current_run, receipt
|
|
575
|
+
|
|
576
|
+
prime_run = (
|
|
577
|
+
PrimeRun(session_state, prime_original_input)
|
|
578
|
+
if legacy_prime and getattr(session_state, "prime_enabled", False) is True
|
|
579
|
+
else None
|
|
580
|
+
)
|
|
581
|
+
prime_token = current_run.set(prime_run)
|
|
582
|
+
from novacode_cli.handoff import HandoffRun, current_handoff
|
|
583
|
+
|
|
584
|
+
handoff_run = None
|
|
585
|
+
handoff_token = current_handoff.set(None)
|
|
586
|
+
from novacode_cli.config.plan_mode import active_plan_session
|
|
587
|
+
|
|
588
|
+
plan_session_token = active_plan_session.set(session_state)
|
|
589
|
+
from novacode_cli.security.approval_context import current_user_request
|
|
590
|
+
|
|
591
|
+
approval_request_token = current_user_request.set(prime_original_input)
|
|
592
|
+
from novacode_cli.core.code_approvals import CodeApprovals, current_code_approvals
|
|
593
|
+
|
|
594
|
+
code_approvals = CodeApprovals(lambda: session_state.thread_id == thread_id)
|
|
595
|
+
code_approvals_token = current_code_approvals.set(code_approvals)
|
|
596
|
+
prime_outcome = "interrupted"
|
|
597
|
+
|
|
598
|
+
# Resuming after an interrupt restarts LangGraph's recursion counter, so a
|
|
599
|
+
# rejection the *policy* resolves on its own (plan-mode block, deny rule) can
|
|
600
|
+
# spin forever: reject -> model retries the same call -> reject -> ... with no
|
|
601
|
+
# user in the way and no step limit to hit. LoopGuardMiddleware can't see it
|
|
602
|
+
# (the tool never executes), so cap the streak here.
|
|
603
|
+
auto_reject_streak = 0
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
if getattr(session_state, "handoff_enabled", False) is True:
|
|
607
|
+
handoff_run = await asyncio.to_thread(
|
|
608
|
+
HandoffRun, session_state, prime_original_input, assistant_id or "nova-agent",
|
|
609
|
+
)
|
|
610
|
+
current_handoff.set(handoff_run)
|
|
611
|
+
if handoff_run is not None and handoff_run.error:
|
|
612
|
+
yield ev.ContextMessage(handoff_run.error, event_type="nova_handoff_status")
|
|
613
|
+
if handoff_run is not None and handoff_run.skill_warning:
|
|
614
|
+
yield ev.ContextMessage(handoff_run.skill_warning, event_type="nova_handoff_status")
|
|
615
|
+
if prime_run is not None:
|
|
616
|
+
yield ev.ContextMessage(
|
|
617
|
+
"Prime · Define · 0/6 checkpoints",
|
|
618
|
+
event_type="nova_prime_progress",
|
|
619
|
+
icon="◆",
|
|
620
|
+
color="cyan",
|
|
621
|
+
record_id=prime_run.report["id"],
|
|
622
|
+
data=prime_run.progress(),
|
|
623
|
+
)
|
|
624
|
+
while True:
|
|
625
|
+
interrupt_occurred = False
|
|
626
|
+
user_prompted = False
|
|
627
|
+
hitl_response: dict[str, Any] = {}
|
|
628
|
+
command_state_update: dict = {}
|
|
629
|
+
# Collected interrupts to resolve after the stream pauses:
|
|
630
|
+
# list of (interrupt_id, kind, payload)
|
|
631
|
+
pending_interrupts: list[tuple[str, str, Any]] = []
|
|
632
|
+
subagent_tracker.clear()
|
|
633
|
+
current_ai_message_id = None
|
|
634
|
+
# task_id -> last status emitted for an async (remote) dispatch. The
|
|
635
|
+
# launch/check/update tools each write the task back into graph state,
|
|
636
|
+
# so the same entry arrives repeatedly; only a status change is news.
|
|
637
|
+
emitted_async: dict[str, str] = {}
|
|
638
|
+
# Subagent message ids whose text already streamed as chunks, so the
|
|
639
|
+
# whole message that follows is not previewed a second time.
|
|
640
|
+
preview_streamed: set = set()
|
|
641
|
+
|
|
642
|
+
_current_stream_gen = scoped_stream(
|
|
643
|
+
traced_stream(
|
|
644
|
+
agent.astream(
|
|
645
|
+
stream_input,
|
|
646
|
+
# "custom" carries the quickjs eval bridge's per-dispatch
|
|
647
|
+
# subagent events, which is how a fan-out is visible while it
|
|
648
|
+
# runs. Anything else on that stream is ignored downstream.
|
|
649
|
+
stream_mode=["updates", "messages", "custom"],
|
|
650
|
+
subgraphs=True,
|
|
651
|
+
config=config,
|
|
652
|
+
durability="exit",
|
|
653
|
+
)
|
|
654
|
+
),
|
|
655
|
+
"main",
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
async for chunk in _safe_stream(_current_stream_gen):
|
|
659
|
+
# Surface any Nova learning events (review cycles) as they happen
|
|
660
|
+
# so the UI's live indicator updates mid-turn rather than only at
|
|
661
|
+
# turn boundaries.
|
|
662
|
+
for _ctx in _drain_nova_events():
|
|
663
|
+
yield _ctx
|
|
664
|
+
|
|
665
|
+
if not isinstance(chunk, tuple) or len(chunk) != 3:
|
|
666
|
+
continue
|
|
667
|
+
_namespace, current_stream_mode, data = chunk
|
|
668
|
+
|
|
669
|
+
if current_stream_mode == "custom":
|
|
670
|
+
if isinstance(data, dict) and data.get("type") == "nova_skill_read":
|
|
671
|
+
name = " ".join(str(data.get("name") or "").split())[:200]
|
|
672
|
+
if name:
|
|
673
|
+
yield ev.ContextMessage(
|
|
674
|
+
message=f"Reading skill: {name}",
|
|
675
|
+
event_type="nova_skill_read",
|
|
676
|
+
icon="📖",
|
|
677
|
+
color="cyan",
|
|
678
|
+
)
|
|
679
|
+
continue
|
|
680
|
+
if isinstance(data, dict) and data.get("type") == "nova_code_approval":
|
|
681
|
+
pending = code_approvals.pending.get(data.get("id"))
|
|
682
|
+
if pending is not None and code_approvals.active:
|
|
683
|
+
payload, future = pending
|
|
684
|
+
if not future.done():
|
|
685
|
+
if session_state.thread_id != thread_id:
|
|
686
|
+
code_approvals.close()
|
|
687
|
+
continue
|
|
688
|
+
approval_payload = {
|
|
689
|
+
**payload, "requires_owner_approval": True,
|
|
690
|
+
"approval_session": runtime_session,
|
|
691
|
+
}
|
|
692
|
+
notice = session_state.add_notification(
|
|
693
|
+
level="approval", title="Tool action requires approval",
|
|
694
|
+
message=format_interrupt_notification("tool", approval_payload),
|
|
695
|
+
source="system", action_id=data["id"], action_type="approve",
|
|
696
|
+
)
|
|
697
|
+
session_state.register_pending_approval(data["id"], future)
|
|
698
|
+
try:
|
|
699
|
+
yield ev.InterruptRequest(
|
|
700
|
+
kind="tool", payload=approval_payload, future=future
|
|
701
|
+
)
|
|
702
|
+
await future
|
|
703
|
+
if session_state.thread_id != thread_id:
|
|
704
|
+
code_approvals.close()
|
|
705
|
+
finally:
|
|
706
|
+
session_state.dismiss_notification(notice)
|
|
707
|
+
continue
|
|
708
|
+
if (
|
|
709
|
+
prime_run is not None
|
|
710
|
+
and prime_run.is_current()
|
|
711
|
+
and isinstance(data, dict)
|
|
712
|
+
and data.get("type") == "nova_prime_checkpoint"
|
|
713
|
+
and data.get("run_id") == prime_run.report["id"]
|
|
714
|
+
):
|
|
715
|
+
yield ev.ContextMessage(
|
|
716
|
+
message=f"Prime · {prime_run.report['phase'].title()} · "
|
|
717
|
+
f"{len(prime_run.report['checkpoints'])}/6 checkpoints",
|
|
718
|
+
event_type="nova_prime_progress",
|
|
719
|
+
icon="◆",
|
|
720
|
+
color="cyan",
|
|
721
|
+
record_id=prime_run.report["id"],
|
|
722
|
+
data=prime_run.progress(),
|
|
723
|
+
)
|
|
724
|
+
continue
|
|
725
|
+
if isinstance(data, dict) and data.get("type") == "nova_model_retry":
|
|
726
|
+
phase = data.get("phase")
|
|
727
|
+
if phase == "retry":
|
|
728
|
+
yield ev.StatusUpdate(
|
|
729
|
+
f"Retrying model request · {data.get('attempt')}/{data.get('maximum')}…"
|
|
730
|
+
)
|
|
731
|
+
elif phase == "recovered":
|
|
732
|
+
yield ev.StatusUpdate("thinking…")
|
|
733
|
+
continue
|
|
734
|
+
# Sent by ContextCompactionMiddleware before it asks for a
|
|
735
|
+
# summary: that call does not stream, so without this the UI
|
|
736
|
+
# would sit silent for as long as the summary takes.
|
|
737
|
+
if isinstance(data, dict) and data.get("type") == "nova_compaction_start":
|
|
738
|
+
subagent_task_id = data.get("subagent_task_id")
|
|
739
|
+
if subagent_task_id:
|
|
740
|
+
yield ev.SubagentCompaction(str(subagent_task_id), True)
|
|
741
|
+
continue
|
|
742
|
+
compaction_finished = False
|
|
743
|
+
usage_after_compaction = False
|
|
744
|
+
if not compaction_status_started:
|
|
745
|
+
compaction_status_started = True
|
|
746
|
+
yield ev.ContextMessage(
|
|
747
|
+
message="Context is compacting",
|
|
748
|
+
event_type="nova_compaction_start",
|
|
749
|
+
icon="⟳",
|
|
750
|
+
color="dim",
|
|
751
|
+
)
|
|
752
|
+
continue
|
|
753
|
+
# ...and after it, whether it worked or not, so the status
|
|
754
|
+
# ends with the summary rather than with the turn.
|
|
755
|
+
if isinstance(data, dict) and data.get("type") in (
|
|
756
|
+
"nova_compaction_end",
|
|
757
|
+
"nova_compaction_failed",
|
|
758
|
+
):
|
|
759
|
+
subagent_task_id = data.get("subagent_task_id")
|
|
760
|
+
if subagent_task_id:
|
|
761
|
+
yield ev.SubagentCompaction(str(subagent_task_id), False)
|
|
762
|
+
continue
|
|
763
|
+
compaction_finished = True
|
|
764
|
+
usage_after_compaction = False
|
|
765
|
+
if compaction_status_started:
|
|
766
|
+
compaction_status_started = False
|
|
767
|
+
ok = data["type"] == "nova_compaction_end"
|
|
768
|
+
yield ev.ContextMessage(
|
|
769
|
+
message="Context compacted"
|
|
770
|
+
if ok
|
|
771
|
+
else "Context summary failed; continuing with the full history",
|
|
772
|
+
event_type=data["type"],
|
|
773
|
+
icon="⟳",
|
|
774
|
+
color="dim" if ok else "yellow",
|
|
775
|
+
)
|
|
776
|
+
continue
|
|
777
|
+
# A fan-out's tasks, one event each as they start and finish.
|
|
778
|
+
# The mapper rejects every other custom payload, so middleware
|
|
779
|
+
# writing to this stream costs nothing here.
|
|
780
|
+
_task = task_from_custom_event(data, now=time.time())
|
|
781
|
+
if _task is not None:
|
|
782
|
+
yield _task
|
|
783
|
+
continue
|
|
784
|
+
|
|
785
|
+
if current_stream_mode == "updates":
|
|
786
|
+
if not isinstance(data, dict):
|
|
787
|
+
continue
|
|
788
|
+
|
|
789
|
+
chunk_data = None
|
|
790
|
+
for _node_state in data.values():
|
|
791
|
+
if isinstance(_node_state, dict):
|
|
792
|
+
if chunk_data is None:
|
|
793
|
+
chunk_data = _node_state
|
|
794
|
+
if "todos" in _node_state:
|
|
795
|
+
_new_todos = _node_state["todos"]
|
|
796
|
+
yield ev.TodoUpdate(
|
|
797
|
+
todos=_new_todos,
|
|
798
|
+
agent_name=_node_state.get("agent_name"),
|
|
799
|
+
)
|
|
800
|
+
break
|
|
801
|
+
|
|
802
|
+
# Async (remote) subagents, at dispatch rather than at turn end.
|
|
803
|
+
# `start_async_task` returns a Command whose state update carries
|
|
804
|
+
# the new task, so the row is available here the moment the
|
|
805
|
+
# launch tool returns. The Done-time watcher is the only other
|
|
806
|
+
# source, which is why these rows used to appear a whole turn
|
|
807
|
+
# late. It still owns completion notifications (6s polling).
|
|
808
|
+
for _node_state in data.values():
|
|
809
|
+
if not isinstance(_node_state, dict):
|
|
810
|
+
continue
|
|
811
|
+
_async_tasks = _node_state.get("async_tasks")
|
|
812
|
+
if not isinstance(_async_tasks, dict):
|
|
813
|
+
continue
|
|
814
|
+
for _entry in _async_tasks.values():
|
|
815
|
+
_row = task_from_async_entry(_entry, now=time.time())
|
|
816
|
+
if _row is None:
|
|
817
|
+
continue
|
|
818
|
+
if emitted_async.get(_row.task_id) == _row.status:
|
|
819
|
+
continue
|
|
820
|
+
emitted_async[_row.task_id] = _row.status
|
|
821
|
+
yield _row
|
|
822
|
+
|
|
823
|
+
if "__interrupt__" in data:
|
|
824
|
+
interrupts: list[Interrupt] = data["__interrupt__"]
|
|
825
|
+
for interrupt_obj in interrupts:
|
|
826
|
+
interrupt_value = interrupt_obj.value
|
|
827
|
+
# ask_question interrupt
|
|
828
|
+
if isinstance(interrupt_value, dict) and (
|
|
829
|
+
interrupt_value.get("type") == "question"
|
|
830
|
+
or (
|
|
831
|
+
interrupt_value.get("type") == "tool"
|
|
832
|
+
and interrupt_value.get("name")
|
|
833
|
+
in ("ask_question", "ask_user_question")
|
|
834
|
+
)
|
|
835
|
+
):
|
|
836
|
+
pending_interrupts.append(
|
|
837
|
+
(
|
|
838
|
+
interrupt_obj.id,
|
|
839
|
+
"question",
|
|
840
|
+
interrupt_value.get("request", {}),
|
|
841
|
+
)
|
|
842
|
+
)
|
|
843
|
+
interrupt_occurred = True
|
|
844
|
+
continue
|
|
845
|
+
# plan approval interrupt
|
|
846
|
+
if (
|
|
847
|
+
isinstance(interrupt_value, dict)
|
|
848
|
+
and interrupt_value.get("type") == "plan_approval"
|
|
849
|
+
):
|
|
850
|
+
pending_interrupts.append(
|
|
851
|
+
(interrupt_obj.id, "plan", interrupt_value)
|
|
852
|
+
)
|
|
853
|
+
interrupt_occurred = True
|
|
854
|
+
continue
|
|
855
|
+
# generic tool HITL interrupt
|
|
856
|
+
try:
|
|
857
|
+
validated = _HITL_REQUEST_ADAPTER.validate_python(interrupt_value)
|
|
858
|
+
pending_interrupts.append((
|
|
859
|
+
interrupt_obj.id, "tool",
|
|
860
|
+
{**validated, "approval_session": runtime_session},
|
|
861
|
+
))
|
|
862
|
+
interrupt_occurred = True
|
|
863
|
+
except ValidationError as e:
|
|
864
|
+
yield ev.Error(f"Invalid HITL request data: {e}")
|
|
865
|
+
raise
|
|
866
|
+
|
|
867
|
+
# summarization detection
|
|
868
|
+
if chunk_data and isinstance(chunk_data, dict):
|
|
869
|
+
_msgs = chunk_data.get("messages")
|
|
870
|
+
if _msgs is not None:
|
|
871
|
+
if hasattr(_msgs, "value"):
|
|
872
|
+
_msgs = _msgs.value
|
|
873
|
+
if not isinstance(_msgs, list):
|
|
874
|
+
try:
|
|
875
|
+
_msgs = list(_msgs)
|
|
876
|
+
except TypeError:
|
|
877
|
+
_msgs = []
|
|
878
|
+
for _msg in _msgs:
|
|
879
|
+
# A summary already in the thread (a /resume seed)
|
|
880
|
+
# comes back whenever a node rewrites the history.
|
|
881
|
+
if (
|
|
882
|
+
getattr(_msg, "additional_kwargs", {}).get("lc_source")
|
|
883
|
+
== "summarization"
|
|
884
|
+
and getattr(_msg, "id", None) not in seen_message_ids
|
|
885
|
+
):
|
|
886
|
+
_post_summarization = True
|
|
887
|
+
if not compaction_status_started:
|
|
888
|
+
compaction_status_started = True
|
|
889
|
+
yield ev.ContextMessage(
|
|
890
|
+
message="Context is compacting",
|
|
891
|
+
event_type="nova_compaction_start",
|
|
892
|
+
icon="⟳",
|
|
893
|
+
color="dim",
|
|
894
|
+
)
|
|
895
|
+
break
|
|
896
|
+
|
|
897
|
+
elif current_stream_mode == "messages":
|
|
898
|
+
is_main_agent = _namespace == ()
|
|
899
|
+
is_subagent = _namespace != ()
|
|
900
|
+
subagent_type_for_ns = _namespace[0] if is_subagent and _namespace else None
|
|
901
|
+
|
|
902
|
+
if not isinstance(data, tuple) or len(data) != 2:
|
|
903
|
+
continue
|
|
904
|
+
message, _metadata = data
|
|
905
|
+
# Which panel row this subagent message belongs to (stamped by
|
|
906
|
+
# the task tool, see core_agent._build_task_tool_with_task_id).
|
|
907
|
+
preview_id = (
|
|
908
|
+
_metadata.get(TASK_ID_METADATA_KEY)
|
|
909
|
+
if is_subagent and isinstance(_metadata, dict)
|
|
910
|
+
else None
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
# Hermes runs its review/skill-refine model calls out-of-band
|
|
914
|
+
# as fire-and-forget asyncio tasks spawned from inside the
|
|
915
|
+
# agent's model call. Those tasks inherit LangGraph's message
|
|
916
|
+
# streaming callback via contextvars, so their full output
|
|
917
|
+
# (e.g. a regenerated SKILL.md) would otherwise surface here as
|
|
918
|
+
# a "Nova" assistant message. They're tagged nova_oob — drop
|
|
919
|
+
# them; their user-facing notices come via nova_event_log.
|
|
920
|
+
# Summary requests must not replace main-model context usage.
|
|
921
|
+
if isinstance(_metadata, dict) and (
|
|
922
|
+
_metadata.get("nova_oob")
|
|
923
|
+
or _metadata.get("lc_source") == "summarization"
|
|
924
|
+
or "hermes" in (_metadata.get("tags") or [])
|
|
925
|
+
):
|
|
926
|
+
continue
|
|
927
|
+
|
|
928
|
+
_ckpt_ns = (
|
|
929
|
+
_metadata.get("langgraph_checkpoint_ns", "")
|
|
930
|
+
if isinstance(_metadata, dict)
|
|
931
|
+
else ""
|
|
932
|
+
)
|
|
933
|
+
if _ckpt_ns and "before_model" in _ckpt_ns:
|
|
934
|
+
continue
|
|
935
|
+
|
|
936
|
+
if getattr(message, "id", None) in seen_before_turn:
|
|
937
|
+
continue
|
|
938
|
+
message_already_seen = getattr(message, "id", None) in seen_message_ids
|
|
939
|
+
if isinstance(message, HumanMessage):
|
|
940
|
+
continue
|
|
941
|
+
|
|
942
|
+
if isinstance(message, ToolMessage):
|
|
943
|
+
async for _e in _handle_tool_message(
|
|
944
|
+
message,
|
|
945
|
+
namespace=_namespace,
|
|
946
|
+
is_main_agent=is_main_agent,
|
|
947
|
+
is_subagent=is_subagent,
|
|
948
|
+
tool_call_to_name=tool_call_to_name,
|
|
949
|
+
displayed_tool_ids=displayed_tool_ids,
|
|
950
|
+
seen_message_ids=seen_message_ids,
|
|
951
|
+
file_op_tracker=file_op_tracker,
|
|
952
|
+
subagent_tracker=subagent_tracker,
|
|
953
|
+
agent_display_name=agent_display_name,
|
|
954
|
+
flush_text=_flush_events,
|
|
955
|
+
preview_task_id=preview_id,
|
|
956
|
+
):
|
|
957
|
+
yield _e
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
# Build content blocks
|
|
961
|
+
if hasattr(message, "content_blocks"):
|
|
962
|
+
blocks: list[dict] = message.content_blocks
|
|
963
|
+
else:
|
|
964
|
+
blocks = []
|
|
965
|
+
raw = getattr(message, "content", "")
|
|
966
|
+
if isinstance(raw, str) and raw:
|
|
967
|
+
blocks.append({"type": "text", "text": raw})
|
|
968
|
+
elif isinstance(raw, list):
|
|
969
|
+
for item in raw:
|
|
970
|
+
if isinstance(item, dict):
|
|
971
|
+
blocks.append(item)
|
|
972
|
+
elif isinstance(item, str) and item:
|
|
973
|
+
blocks.append({"type": "text", "text": item})
|
|
974
|
+
for tc in getattr(message, "tool_call_chunks", []) or []:
|
|
975
|
+
if isinstance(tc, dict):
|
|
976
|
+
blocks.append({"type": "tool_call_chunk", **tc})
|
|
977
|
+
|
|
978
|
+
# Usage capture (main agent). MUST run before the empty-blocks
|
|
979
|
+
# early-continue below: providers (Ollama, OpenAI, Anthropic)
|
|
980
|
+
# attach usage_metadata to a *final chunk with empty content*,
|
|
981
|
+
# which has no blocks — skipping it here lost all token counts.
|
|
982
|
+
if is_main_agent:
|
|
983
|
+
from types import SimpleNamespace
|
|
984
|
+
|
|
985
|
+
from novacode_cli.tracking.usage_tree import _extract_usage
|
|
986
|
+
|
|
987
|
+
usage = _extract_usage(SimpleNamespace(
|
|
988
|
+
generations=[[SimpleNamespace(message=message)]]
|
|
989
|
+
))
|
|
990
|
+
if usage:
|
|
991
|
+
cache_read = usage.get("cache_read_tokens", 0)
|
|
992
|
+
cache_create = usage.get("cache_creation_tokens", 0)
|
|
993
|
+
actual = usage.get("input_tokens", 0)
|
|
994
|
+
out = usage.get("output_tokens", 0)
|
|
995
|
+
if actual or out:
|
|
996
|
+
metadata = _metadata if isinstance(_metadata, dict) else {}
|
|
997
|
+
call_id = getattr(message, "id", None) or (
|
|
998
|
+
metadata.get("langgraph_step"),
|
|
999
|
+
metadata.get("langgraph_node"),
|
|
1000
|
+
metadata.get("langgraph_checkpoint_ns"),
|
|
1001
|
+
)
|
|
1002
|
+
old_input, old_output, old_cache_read, old_cache_create = (
|
|
1003
|
+
usage_calls.get(call_id, (0, 0, 0, 0))
|
|
1004
|
+
)
|
|
1005
|
+
usage_calls[call_id] = (
|
|
1006
|
+
max(old_input, actual),
|
|
1007
|
+
max(old_output, out),
|
|
1008
|
+
max(old_cache_read, int(cache_read or 0)),
|
|
1009
|
+
max(old_cache_create, int(cache_create or 0)),
|
|
1010
|
+
)
|
|
1011
|
+
call_usage = usage_calls[call_id]
|
|
1012
|
+
captured.input_tokens, captured.output_tokens = call_usage[:2]
|
|
1013
|
+
if compaction_finished and captured.input_tokens > 0:
|
|
1014
|
+
usage_after_compaction = True
|
|
1015
|
+
captured.cache_read_tokens = sum(u[2] for u in usage_calls.values())
|
|
1016
|
+
captured.cache_creation_tokens = sum(
|
|
1017
|
+
u[3] for u in usage_calls.values()
|
|
1018
|
+
)
|
|
1019
|
+
captured.session_tokens = sum(
|
|
1020
|
+
i + o for i, o, _, _ in usage_calls.values()
|
|
1021
|
+
)
|
|
1022
|
+
captured.session_input_tokens = sum(
|
|
1023
|
+
i for i, _, _, _ in usage_calls.values()
|
|
1024
|
+
)
|
|
1025
|
+
if captured.input_tokens != context_shown:
|
|
1026
|
+
context_shown = captured.input_tokens
|
|
1027
|
+
yield ev.ContextUsage(context_shown)
|
|
1028
|
+
|
|
1029
|
+
_is_completed_msg = type(message).__name__ == "AIMessage"
|
|
1030
|
+
reasoning_key = (str(_namespace), getattr(message, "id", None))
|
|
1031
|
+
thought = reasoning_text(message, blocks) if is_main_agent else ""
|
|
1032
|
+
if thought and (
|
|
1033
|
+
not _is_completed_msg
|
|
1034
|
+
or reasoning_key[1] is None
|
|
1035
|
+
or reasoning_key not in streamed_reasoning_ids
|
|
1036
|
+
):
|
|
1037
|
+
yield ev.ReasoningDelta(thought)
|
|
1038
|
+
if reasoning_key[1] is not None:
|
|
1039
|
+
streamed_reasoning_ids.add(reasoning_key)
|
|
1040
|
+
if text_guard.feed(thought):
|
|
1041
|
+
raise _RepetitionLoop
|
|
1042
|
+
|
|
1043
|
+
if message_already_seen or not blocks:
|
|
1044
|
+
continue
|
|
1045
|
+
|
|
1046
|
+
for block in blocks:
|
|
1047
|
+
btype = block.get("type")
|
|
1048
|
+
if btype == "text" and block.get("thought") is not True:
|
|
1049
|
+
text = block.get("text", "")
|
|
1050
|
+
if text and is_main_agent and not _is_completed_msg:
|
|
1051
|
+
current_ai_message_id = getattr(message, "id", None)
|
|
1052
|
+
pending_text += text
|
|
1053
|
+
if looks_like_summarization_output(pending_text):
|
|
1054
|
+
summary_preview_suppressed = True
|
|
1055
|
+
if not compaction_status_started:
|
|
1056
|
+
compaction_status_started = True
|
|
1057
|
+
yield ev.ContextMessage(
|
|
1058
|
+
message="Context is compacting",
|
|
1059
|
+
event_type="nova_compaction_start",
|
|
1060
|
+
icon="⟳",
|
|
1061
|
+
color="dim",
|
|
1062
|
+
)
|
|
1063
|
+
if not summary_preview_suppressed and (
|
|
1064
|
+
preview_started
|
|
1065
|
+
or not _could_be_session_intent_heading(pending_text)
|
|
1066
|
+
):
|
|
1067
|
+
preview_text = text if preview_started else pending_text
|
|
1068
|
+
preview_started = True
|
|
1069
|
+
_streamed_pending = True
|
|
1070
|
+
yield ev.TextDelta(preview_text)
|
|
1071
|
+
if text_guard.feed(text):
|
|
1072
|
+
raise _RepetitionLoop
|
|
1073
|
+
elif text and preview_id:
|
|
1074
|
+
# Chunks as they stream; a model that does not
|
|
1075
|
+
# stream delivers one whole message instead.
|
|
1076
|
+
_mid = getattr(message, "id", None)
|
|
1077
|
+
if not _is_completed_msg:
|
|
1078
|
+
preview_streamed.add(_mid)
|
|
1079
|
+
yield ev.SubagentPreview(preview_id, "text", text)
|
|
1080
|
+
elif _mid not in preview_streamed:
|
|
1081
|
+
yield ev.SubagentPreview(preview_id, "text", text)
|
|
1082
|
+
elif btype in ("tool_call_chunk", "tool_call"):
|
|
1083
|
+
async for _e in _handle_tool_call_chunk(
|
|
1084
|
+
block,
|
|
1085
|
+
is_main_agent=is_main_agent,
|
|
1086
|
+
namespace=_namespace,
|
|
1087
|
+
subagent_type_for_ns=subagent_type_for_ns,
|
|
1088
|
+
tool_call_buffers=tool_call_buffers,
|
|
1089
|
+
displayed_tool_ids=displayed_tool_ids,
|
|
1090
|
+
tool_call_to_name=tool_call_to_name,
|
|
1091
|
+
file_op_tracker=file_op_tracker,
|
|
1092
|
+
subagent_tracker=subagent_tracker,
|
|
1093
|
+
flush_text=_flush_events,
|
|
1094
|
+
preview_task_id=preview_id,
|
|
1095
|
+
):
|
|
1096
|
+
# The agent can self-engage plan mode: when it calls
|
|
1097
|
+
# enter_plan_mode, flip the session flag so the
|
|
1098
|
+
# pre-HITL gate (check_plan_mode_blocked) starts
|
|
1099
|
+
# blocking writes/shell for the rest of the turn —
|
|
1100
|
+
# making the agent read-only until exit_plan_mode is
|
|
1101
|
+
# approved. exit_plan_mode's approval clears it.
|
|
1102
|
+
if (
|
|
1103
|
+
isinstance(_e, ev.ToolCall)
|
|
1104
|
+
and _e.name == "enter_plan_mode"
|
|
1105
|
+
and is_main_agent
|
|
1106
|
+
):
|
|
1107
|
+
try:
|
|
1108
|
+
session_state.plan_mode_enabled = True
|
|
1109
|
+
except Exception: # noqa: BLE001
|
|
1110
|
+
pass
|
|
1111
|
+
# A tool call is proof the turn is advancing, so
|
|
1112
|
+
# prose repeats from before it must not count
|
|
1113
|
+
# toward the "going nowhere" verdict. The guard
|
|
1114
|
+
# exists for prose with NO tool calls at all;
|
|
1115
|
+
# without this reset it killed long productive
|
|
1116
|
+
# turns whose narration repeats around each step.
|
|
1117
|
+
if isinstance(_e, ev.ToolCall):
|
|
1118
|
+
text_guard.note_progress()
|
|
1119
|
+
yield _e
|
|
1120
|
+
|
|
1121
|
+
if getattr(message, "chunk_position", None) == "last":
|
|
1122
|
+
if hasattr(message, "id"):
|
|
1123
|
+
seen_message_ids.add(message.id)
|
|
1124
|
+
for _e in _flush_events():
|
|
1125
|
+
yield _e
|
|
1126
|
+
|
|
1127
|
+
_current_stream_gen = None
|
|
1128
|
+
|
|
1129
|
+
# Flush any trailing prose.
|
|
1130
|
+
for _e in _flush_events():
|
|
1131
|
+
yield _e
|
|
1132
|
+
|
|
1133
|
+
# Resolve interrupts (graph is paused).
|
|
1134
|
+
if interrupt_occurred:
|
|
1135
|
+
any_rejected = False
|
|
1136
|
+
plan_approved = False
|
|
1137
|
+
plan_handoff_required = False
|
|
1138
|
+
# (plan text, approved by Nova itself?) for the approved plan.
|
|
1139
|
+
approved_plan: tuple[str, bool] | None = None
|
|
1140
|
+
# When the turn auto-approves (e.g. a /remote turn sets
|
|
1141
|
+
# auto_approve=True), no permission is actually being asked of the
|
|
1142
|
+
# user — the interrupt is resolved automatically — so a badge /
|
|
1143
|
+
# desktop notification would just be noise. Skip it there; local,
|
|
1144
|
+
# interactive turns (auto_approve off) still notify.
|
|
1145
|
+
_auto_approve = bool(getattr(session_state, "auto_approve", False))
|
|
1146
|
+
from novacode_cli.ui.hitl_approval import evaluate_tool_actions
|
|
1147
|
+
|
|
1148
|
+
for interrupt_id, kind, payload in pending_interrupts:
|
|
1149
|
+
# Pre-HITL policy gate: auto-resolve the actions that don't need
|
|
1150
|
+
# a prompt (policy-allow → approve, policy-deny → reject, plus
|
|
1151
|
+
# plan-mode/auto_approve) so the safe majority never interrupts
|
|
1152
|
+
# and hard-denies block without asking. Only a genuine "ask"
|
|
1153
|
+
# falls through to surface an InterruptRequest + notification.
|
|
1154
|
+
_policy_resolutions: list[dict | None] | None = None
|
|
1155
|
+
if kind == "plan" and _auto_approve:
|
|
1156
|
+
# Auto-approve plan without prompting
|
|
1157
|
+
# auto_approved, not mode: the TUI's "approve + auto-accept
|
|
1158
|
+
# edits" choice is ALSO mode "auto", and that one is the
|
|
1159
|
+
# user approving. This flag means nobody was asked.
|
|
1160
|
+
hitl_response[interrupt_id] = {
|
|
1161
|
+
"approved": True,
|
|
1162
|
+
"mode": "auto",
|
|
1163
|
+
"auto_approved": True,
|
|
1164
|
+
}
|
|
1165
|
+
command_state_update.update({"plan_mode_enabled": False})
|
|
1166
|
+
plan_approved = True
|
|
1167
|
+
approved_plan = ((payload or {}).get("plan") or "", True)
|
|
1168
|
+
|
|
1169
|
+
# Set plan_mode_enabled=False on session_state and clear plan agent
|
|
1170
|
+
session_state.plan_mode_enabled = False
|
|
1171
|
+
using_separate_plan_agent = (
|
|
1172
|
+
hasattr(session_state, "plan_agent")
|
|
1173
|
+
and session_state.plan_agent is not None
|
|
1174
|
+
)
|
|
1175
|
+
plan_handoff_required = using_separate_plan_agent or bool(
|
|
1176
|
+
getattr(session_state, "plan_execution_handoff", False)
|
|
1177
|
+
)
|
|
1178
|
+
# Keep the planner/backend alive until the shared handoff
|
|
1179
|
+
# resolves plans that were written to .nova/plans/.
|
|
1180
|
+
session_state.plan_content = None
|
|
1181
|
+
|
|
1182
|
+
yield ev.ContextMessage(
|
|
1183
|
+
"Plan Approved - switching to execution mode (auto-approved)",
|
|
1184
|
+
icon="✓",
|
|
1185
|
+
color="green",
|
|
1186
|
+
)
|
|
1187
|
+
continue
|
|
1188
|
+
|
|
1189
|
+
if kind == "tool":
|
|
1190
|
+
try:
|
|
1191
|
+
_policy_resolutions = await asyncio.to_thread(
|
|
1192
|
+
evaluate_tool_actions,
|
|
1193
|
+
payload,
|
|
1194
|
+
session_state,
|
|
1195
|
+
plan_mode_enabled=getattr(session_state, "plan_mode_enabled", False),
|
|
1196
|
+
user_request=message_content,
|
|
1197
|
+
)
|
|
1198
|
+
except Exception: # noqa: BLE001 — never break the turn
|
|
1199
|
+
_policy_resolutions = None
|
|
1200
|
+
if _policy_resolutions is not None and all(
|
|
1201
|
+
r is not None for r in _policy_resolutions
|
|
1202
|
+
):
|
|
1203
|
+
_decided = [r for r in _policy_resolutions if r is not None]
|
|
1204
|
+
hitl_response[interrupt_id] = {"decisions": _decided}
|
|
1205
|
+
if any(d.get("type") == "reject" for d in _decided):
|
|
1206
|
+
any_rejected = True
|
|
1207
|
+
action_requests = (
|
|
1208
|
+
payload.get("action_requests", [])
|
|
1209
|
+
if isinstance(payload, dict)
|
|
1210
|
+
else []
|
|
1211
|
+
)
|
|
1212
|
+
for index, decision in enumerate(_decided):
|
|
1213
|
+
source = decision.get("approval_source")
|
|
1214
|
+
if source not in {"decision_model", "safety_filter", "unavailable"}:
|
|
1215
|
+
continue
|
|
1216
|
+
action = (
|
|
1217
|
+
action_requests[index]
|
|
1218
|
+
if index < len(action_requests)
|
|
1219
|
+
and isinstance(action_requests[index], dict)
|
|
1220
|
+
else {}
|
|
1221
|
+
)
|
|
1222
|
+
approved = decision.get("type") == "approve"
|
|
1223
|
+
action_name = action.get("name", "tool action")
|
|
1224
|
+
if approved:
|
|
1225
|
+
model_name = decision.get("decision_model", "Decision model")
|
|
1226
|
+
message = f"Decision model {model_name} approved {action_name}"
|
|
1227
|
+
icon, color = "✓", "green"
|
|
1228
|
+
else:
|
|
1229
|
+
if source == "safety_filter":
|
|
1230
|
+
message = f"Safety filter rejected {action_name}"
|
|
1231
|
+
elif source == "unavailable":
|
|
1232
|
+
message = f"Decision model unavailable; rejected {action_name}"
|
|
1233
|
+
else:
|
|
1234
|
+
model_name = decision.get("decision_model", "Decision model")
|
|
1235
|
+
message = f"Decision model {model_name} rejected {action_name}"
|
|
1236
|
+
icon, color = "✗", "red"
|
|
1237
|
+
if decision.get("reason"):
|
|
1238
|
+
message += f" · {decision['reason']}"
|
|
1239
|
+
yield ev.ContextMessage(message, icon=icon, color=color)
|
|
1240
|
+
continue
|
|
1241
|
+
|
|
1242
|
+
# A genuine ask remains interactive even in auto mode.
|
|
1243
|
+
# Resolvers must not run the reviewer a second time.
|
|
1244
|
+
approval_payload = (
|
|
1245
|
+
{**payload, "requires_owner_approval": True} if kind == "tool" else payload
|
|
1246
|
+
)
|
|
1247
|
+
fut: asyncio.Future = loop.create_future()
|
|
1248
|
+
|
|
1249
|
+
# Raise the notification BEFORE surfacing the interrupt, so the
|
|
1250
|
+
# badge/notification hook fire the moment approval is needed —
|
|
1251
|
+
# not after the modal is answered (the old order ran this only
|
|
1252
|
+
# once the generator resumed past the yield). Skipped entirely
|
|
1253
|
+
# when auto-approving (see above).
|
|
1254
|
+
_notif_id: str | None = None
|
|
1255
|
+
if kind in ("tool", "plan", "question") and (
|
|
1256
|
+
not _auto_approve or kind == "tool"
|
|
1257
|
+
):
|
|
1258
|
+
_notif_msg = (
|
|
1259
|
+
"Plan requires approval"
|
|
1260
|
+
if kind == "plan"
|
|
1261
|
+
else (
|
|
1262
|
+
"Tool action requires approval"
|
|
1263
|
+
if kind == "tool"
|
|
1264
|
+
else "Question requires response"
|
|
1265
|
+
)
|
|
1266
|
+
)
|
|
1267
|
+
try:
|
|
1268
|
+
_notif_id = session_state.add_notification(
|
|
1269
|
+
level="approval",
|
|
1270
|
+
title=_notif_msg,
|
|
1271
|
+
message=format_interrupt_notification(kind, approval_payload),
|
|
1272
|
+
source="system",
|
|
1273
|
+
action_id=interrupt_id,
|
|
1274
|
+
action_type=("approve" if kind in ("tool", "plan") else "select"),
|
|
1275
|
+
)
|
|
1276
|
+
session_state.register_pending_approval(interrupt_id, fut)
|
|
1277
|
+
except Exception: # noqa: BLE001 — never break the turn on a notification
|
|
1278
|
+
pass
|
|
1279
|
+
|
|
1280
|
+
user_prompted = True
|
|
1281
|
+
yield ev.InterruptRequest(kind=kind, payload=approval_payload, future=fut)
|
|
1282
|
+
response = await fut
|
|
1283
|
+
|
|
1284
|
+
# Resolved (via the modal or /notifications) — clear the badge
|
|
1285
|
+
# so a settled approval doesn't linger as "pending".
|
|
1286
|
+
if _notif_id is not None:
|
|
1287
|
+
try:
|
|
1288
|
+
session_state.dismiss_notification(_notif_id)
|
|
1289
|
+
except Exception: # noqa: BLE001
|
|
1290
|
+
pass
|
|
1291
|
+
if kind == "tool":
|
|
1292
|
+
_user_decisions = list(response.get("decisions", []))
|
|
1293
|
+
# Overlay policy verdicts onto the user's batch decision:
|
|
1294
|
+
# policy-decided slots (allow/deny) win, only the "ask"
|
|
1295
|
+
# slots take the user's choice — so a hard deny in a mixed
|
|
1296
|
+
# batch is honored even if the user approved the prompt.
|
|
1297
|
+
if _policy_resolutions is not None:
|
|
1298
|
+
_final: list[dict] = []
|
|
1299
|
+
for _i, _r in enumerate(_policy_resolutions):
|
|
1300
|
+
if _r is not None:
|
|
1301
|
+
_final.append(_r)
|
|
1302
|
+
elif _i < len(_user_decisions):
|
|
1303
|
+
_final.append(_user_decisions[_i])
|
|
1304
|
+
else:
|
|
1305
|
+
_final.append({"type": "reject", "message": "No decision"})
|
|
1306
|
+
_user_decisions = _final
|
|
1307
|
+
hitl_response[interrupt_id] = {"decisions": _user_decisions}
|
|
1308
|
+
if response.get("any_rejected") or any(
|
|
1309
|
+
d.get("type") == "reject" for d in _user_decisions
|
|
1310
|
+
):
|
|
1311
|
+
any_rejected = True
|
|
1312
|
+
elif kind == "question":
|
|
1313
|
+
hitl_response[interrupt_id] = response
|
|
1314
|
+
elif kind == "plan":
|
|
1315
|
+
resp_data = response.get("response", {})
|
|
1316
|
+
hitl_response[interrupt_id] = resp_data
|
|
1317
|
+
su = response.get("state_update")
|
|
1318
|
+
if su:
|
|
1319
|
+
command_state_update.update(su)
|
|
1320
|
+
# Mirror the approval onto THIS loop's session_state,
|
|
1321
|
+
# not just the graph update. The pre-HITL gate reads
|
|
1322
|
+
# session_state.plan_mode_enabled, so a resolver that
|
|
1323
|
+
# can only reach a copy of the state — the parent TUI
|
|
1324
|
+
# answering a spawned session's approval, where the
|
|
1325
|
+
# flag it clears is a proxy in another process — left
|
|
1326
|
+
# the real flag set and every write stayed auto-
|
|
1327
|
+
# rejected after the plan was approved (plan mode
|
|
1328
|
+
# appeared to hang forever).
|
|
1329
|
+
if "plan_mode_enabled" in su:
|
|
1330
|
+
try:
|
|
1331
|
+
session_state.plan_mode_enabled = su["plan_mode_enabled"]
|
|
1332
|
+
except Exception: # noqa: BLE001
|
|
1333
|
+
pass
|
|
1334
|
+
# When the plan is approved, don't resume the plan agent —
|
|
1335
|
+
# the caller (TUI or CLI) handles the hand-off to the main
|
|
1336
|
+
# execution agent. Resuming would let the model continue its
|
|
1337
|
+
# turn and potentially re-enter plan mode.
|
|
1338
|
+
if resp_data.get("approved"):
|
|
1339
|
+
plan_approved = True
|
|
1340
|
+
_inline = payload.get("plan") if isinstance(payload, dict) else ""
|
|
1341
|
+
approved_plan = (_inline or "", False)
|
|
1342
|
+
plan_handoff_required = (
|
|
1343
|
+
getattr(session_state, "plan_agent", None) is not None
|
|
1344
|
+
or bool(getattr(session_state, "plan_execution_handoff", False))
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
if any_rejected:
|
|
1348
|
+
yield ev.ErrorOutput("Command rejected. Tell the agent what to do differently.")
|
|
1349
|
+
|
|
1350
|
+
# Only a rejection nobody was asked about can loop unattended.
|
|
1351
|
+
if any_rejected and not user_prompted:
|
|
1352
|
+
auto_reject_streak += 1
|
|
1353
|
+
if auto_reject_streak >= MAX_AUTO_REJECT_RESUMES:
|
|
1354
|
+
yield ev.Error(
|
|
1355
|
+
f"Stopped: the agent retried a blocked action "
|
|
1356
|
+
f"{auto_reject_streak} times in a row and it was rejected "
|
|
1357
|
+
"automatically each time (plan mode or an approval-policy "
|
|
1358
|
+
"deny rule). Tell it to take a different approach, or "
|
|
1359
|
+
"approve the action."
|
|
1360
|
+
)
|
|
1361
|
+
break
|
|
1362
|
+
else:
|
|
1363
|
+
auto_reject_streak = 0
|
|
1364
|
+
|
|
1365
|
+
# The TUI and separate plan agent always hand off through a clean
|
|
1366
|
+
# execution thread. Other non-interactive self-planning callers keep
|
|
1367
|
+
# their legacy in-context behavior.
|
|
1368
|
+
if plan_approved and plan_handoff_required:
|
|
1369
|
+
approved_text, auto_approved = approved_plan or ("", False)
|
|
1370
|
+
try:
|
|
1371
|
+
from novacode_cli.ui.interrupt_handlers import resolve_plan_content
|
|
1372
|
+
|
|
1373
|
+
resolved, plan_file = resolve_plan_content(
|
|
1374
|
+
getattr(session_state, "todos", None),
|
|
1375
|
+
session_state,
|
|
1376
|
+
backend=getattr(session_state, "plan_backend", None) or backend,
|
|
1377
|
+
inline_plan=(
|
|
1378
|
+
approved_text
|
|
1379
|
+
or getattr(session_state, "approved_plan_content", None)
|
|
1380
|
+
),
|
|
1381
|
+
)
|
|
1382
|
+
except Exception: # noqa: BLE001 — approval must not crash on plan lookup
|
|
1383
|
+
resolved, plan_file = approved_text or None, None
|
|
1384
|
+
if not resolved or not resolved.strip():
|
|
1385
|
+
yield ev.Error(
|
|
1386
|
+
"The plan was approved, but Nova could not load its full text; "
|
|
1387
|
+
"execution was not started."
|
|
1388
|
+
)
|
|
1389
|
+
break
|
|
1390
|
+
|
|
1391
|
+
if approved_text or auto_approved or plan_file is None:
|
|
1392
|
+
saved = await asyncio.to_thread(
|
|
1393
|
+
_save_unresumed_plan, (resolved, auto_approved), session_state
|
|
1394
|
+
)
|
|
1395
|
+
if saved:
|
|
1396
|
+
yield ev.ContextMessage(f"Plan saved to {saved}", icon="✓", color="green")
|
|
1397
|
+
try:
|
|
1398
|
+
session_state.set_approved_plan(resolved)
|
|
1399
|
+
except Exception: # noqa: BLE001
|
|
1400
|
+
pass
|
|
1401
|
+
try:
|
|
1402
|
+
session_state.clear_plan_agent()
|
|
1403
|
+
except Exception: # noqa: BLE001
|
|
1404
|
+
pass
|
|
1405
|
+
if plan_file is not None and not approved_text and not auto_approved:
|
|
1406
|
+
yield ev.ContextMessage(
|
|
1407
|
+
f"Using the approved plan from {plan_file}", icon="✓", color="green"
|
|
1408
|
+
)
|
|
1409
|
+
break
|
|
1410
|
+
|
|
1411
|
+
stream_input = Command(
|
|
1412
|
+
resume=hitl_response,
|
|
1413
|
+
update=command_state_update or None,
|
|
1414
|
+
)
|
|
1415
|
+
continue
|
|
1416
|
+
|
|
1417
|
+
break
|
|
1418
|
+
|
|
1419
|
+
# Compaction detection.
|
|
1420
|
+
#
|
|
1421
|
+
# Two shapes, and only one shrinks the message list:
|
|
1422
|
+
# - Nova's own /compact REPLACES history with a summary (count drops).
|
|
1423
|
+
# - ContextCompactionMiddleware does NOT delete messages; it
|
|
1424
|
+
# records a `_summarization_event` carrying a cutoff_index and
|
|
1425
|
+
# rebuilds the effective list from it. The count is unchanged.
|
|
1426
|
+
# Detecting only the count drop meant a library compaction never emitted
|
|
1427
|
+
# CompactionNotice, so the tracker kept the turn's PRE-compaction
|
|
1428
|
+
# API total (its peak) and reported e.g. 85% when the real context was
|
|
1429
|
+
# ~10% — and Nova then auto-compacted an already-compacted conversation.
|
|
1430
|
+
try:
|
|
1431
|
+
if pre_stream_msg_count is not None:
|
|
1432
|
+
_post_state = await agent.aget_state(config)
|
|
1433
|
+
if _post_state is not None:
|
|
1434
|
+
_vals = _post_state.values
|
|
1435
|
+
post_count = len(_vals.get("messages", []))
|
|
1436
|
+
_summarized = _vals.get("_summarization_event")
|
|
1437
|
+
if (
|
|
1438
|
+
_summarized and _summarized != _pre_summarization_event
|
|
1439
|
+
) or post_count < pre_stream_msg_count - 2:
|
|
1440
|
+
compacted = True
|
|
1441
|
+
except Exception:
|
|
1442
|
+
pass
|
|
1443
|
+
|
|
1444
|
+
prime_outcome = "complete"
|
|
1445
|
+
except asyncio.CancelledError:
|
|
1446
|
+
prime_outcome = "cancelled"
|
|
1447
|
+
_gen = _current_stream_gen
|
|
1448
|
+
_current_stream_gen = None
|
|
1449
|
+
if _gen is not None:
|
|
1450
|
+
try:
|
|
1451
|
+
await asyncio.wait_for(asyncio.shield(_gen.aclose()), timeout=2.0)
|
|
1452
|
+
except Exception:
|
|
1453
|
+
pass
|
|
1454
|
+
try:
|
|
1455
|
+
await asyncio.wait_for(
|
|
1456
|
+
asyncio.shield(
|
|
1457
|
+
_record_cancellation(agent, config)
|
|
1458
|
+
),
|
|
1459
|
+
timeout=3.0,
|
|
1460
|
+
)
|
|
1461
|
+
except Exception:
|
|
1462
|
+
pass
|
|
1463
|
+
yield ev.Cancelled()
|
|
1464
|
+
return
|
|
1465
|
+
|
|
1466
|
+
except Exception as e: # noqa: BLE001
|
|
1467
|
+
prime_outcome = "error"
|
|
1468
|
+
# Promptly release the underlying LangGraph stream rather than leaving it
|
|
1469
|
+
# for GC-time finalization.
|
|
1470
|
+
_gen = _current_stream_gen
|
|
1471
|
+
_current_stream_gen = None
|
|
1472
|
+
if _gen is not None:
|
|
1473
|
+
try:
|
|
1474
|
+
await asyncio.wait_for(asyncio.shield(_gen.aclose()), timeout=2.0)
|
|
1475
|
+
except Exception: # noqa: BLE001
|
|
1476
|
+
pass
|
|
1477
|
+
# A GraphInterrupt that reaches here came from a NESTED graph (a `task`
|
|
1478
|
+
# subagent hitting a HITL-gated tool like shell/execute/write_file).
|
|
1479
|
+
# The parent stream only auto-resolves interrupts surfaced as the
|
|
1480
|
+
# top-level `__interrupt__` event, not exceptions bubbling out of a
|
|
1481
|
+
# subagent — so render a concise, actionable note instead of dumping the
|
|
1482
|
+
# raw interrupt value as a scary error.
|
|
1483
|
+
if isinstance(e, _RepetitionLoop):
|
|
1484
|
+
# Drop the runaway preview rather than committing the repeated blob
|
|
1485
|
+
# as a message — the user watched it stream, and rendering it as
|
|
1486
|
+
# markdown is exactly the work that was killing the UI.
|
|
1487
|
+
yield ev.TextDiscard()
|
|
1488
|
+
yield ev.Error(
|
|
1489
|
+
"Stopped: the model started repeating itself verbatim and the "
|
|
1490
|
+
"response was going nowhere. Nothing was committed for this "
|
|
1491
|
+
"turn — rephrase the request or narrow it down."
|
|
1492
|
+
)
|
|
1493
|
+
return
|
|
1494
|
+
try:
|
|
1495
|
+
from langgraph.errors import GraphInterrupt
|
|
1496
|
+
except Exception: # noqa: BLE001
|
|
1497
|
+
GraphInterrupt = () # type: ignore[assignment]
|
|
1498
|
+
if GraphInterrupt and isinstance(e, GraphInterrupt):
|
|
1499
|
+
yield ev.Error(
|
|
1500
|
+
"A subagent requested approval for a guarded tool "
|
|
1501
|
+
"(shell/execute/write) and the run could not auto-approve it. "
|
|
1502
|
+
"Re-run with auto-approve enabled, or avoid shell commands in "
|
|
1503
|
+
"automated flows.",
|
|
1504
|
+
exception=e,
|
|
1505
|
+
)
|
|
1506
|
+
return
|
|
1507
|
+
# Recognised provider failures (usage/rate limit, auth, connectivity)
|
|
1508
|
+
# get a clean, actionable notice instead of a raw SDK string/traceback —
|
|
1509
|
+
# surfaced identically in both front-ends.
|
|
1510
|
+
from novacode_cli.errors import friendly_model_error, is_context_overflow
|
|
1511
|
+
|
|
1512
|
+
# Context-window overflow is recoverable: emit a dedicated event so the
|
|
1513
|
+
# TUI can compact and retry once instead of just showing the error.
|
|
1514
|
+
if is_context_overflow(e):
|
|
1515
|
+
friendly = friendly_model_error(e) or str(e)
|
|
1516
|
+
yield ev.ContextOverflow(friendly, exception=e)
|
|
1517
|
+
return
|
|
1518
|
+
friendly = friendly_model_error(e)
|
|
1519
|
+
if friendly is not None:
|
|
1520
|
+
yield ev.Error(friendly, exception=e, is_provider_notice=True)
|
|
1521
|
+
return
|
|
1522
|
+
yield ev.Error(str(e), exception=e)
|
|
1523
|
+
return
|
|
1524
|
+
finally:
|
|
1525
|
+
code_approvals.close()
|
|
1526
|
+
current_code_approvals.reset(code_approvals_token)
|
|
1527
|
+
_auto_approve_var.reset(token)
|
|
1528
|
+
active_plan_session.reset(plan_session_token)
|
|
1529
|
+
current_user_request.reset(approval_request_token)
|
|
1530
|
+
if prime_run is not None:
|
|
1531
|
+
prime_run.finish(prime_outcome)
|
|
1532
|
+
current_run.reset(prime_token)
|
|
1533
|
+
try:
|
|
1534
|
+
if handoff_run is not None:
|
|
1535
|
+
await asyncio.to_thread(handoff_run.finish, prime_outcome)
|
|
1536
|
+
finally:
|
|
1537
|
+
current_handoff.reset(handoff_token)
|
|
1538
|
+
|
|
1539
|
+
if captured.input_tokens or captured.output_tokens:
|
|
1540
|
+
yield captured
|
|
1541
|
+
if compacted:
|
|
1542
|
+
yield ev.CompactionNotice(preserve_api_context=usage_after_compaction)
|
|
1543
|
+
for _ctx in _drain_nova_events():
|
|
1544
|
+
yield _ctx
|
|
1545
|
+
if prime_run is not None and prime_run.is_current():
|
|
1546
|
+
yield ev.ContextMessage(
|
|
1547
|
+
await asyncio.to_thread(receipt, prime_run.report),
|
|
1548
|
+
event_type="nova_prime_receipt",
|
|
1549
|
+
icon="◆",
|
|
1550
|
+
color="cyan",
|
|
1551
|
+
record_id=prime_run.report["id"],
|
|
1552
|
+
data=prime_run.progress(),
|
|
1553
|
+
)
|
|
1554
|
+
if handoff_run is not None:
|
|
1555
|
+
yield ev.ContextMessage(
|
|
1556
|
+
handoff_run.notice(),
|
|
1557
|
+
event_type="nova_handoff_status",
|
|
1558
|
+
record_id=handoff_run.record["id"],
|
|
1559
|
+
data={"summary_status": handoff_run.record["summary_status"]},
|
|
1560
|
+
)
|
|
1561
|
+
yield ev.Done(had_response=has_responded)
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
async def _handle_tool_message(
|
|
1565
|
+
message,
|
|
1566
|
+
*,
|
|
1567
|
+
namespace,
|
|
1568
|
+
is_main_agent: bool,
|
|
1569
|
+
is_subagent: bool,
|
|
1570
|
+
tool_call_to_name: dict,
|
|
1571
|
+
displayed_tool_ids: set,
|
|
1572
|
+
seen_message_ids: set,
|
|
1573
|
+
file_op_tracker,
|
|
1574
|
+
subagent_tracker: SubagentTracker,
|
|
1575
|
+
agent_display_name: str,
|
|
1576
|
+
flush_text,
|
|
1577
|
+
preview_task_id: str | None = None,
|
|
1578
|
+
) -> AsyncIterator[Any]:
|
|
1579
|
+
"""Translate a ToolMessage into events (results, file ops, subagent status)."""
|
|
1580
|
+
tool_id = getattr(message, "id", None) or getattr(message, "tool_call_id", None)
|
|
1581
|
+
if tool_id and tool_id in seen_message_ids:
|
|
1582
|
+
return
|
|
1583
|
+
if tool_id:
|
|
1584
|
+
seen_message_ids.add(tool_id)
|
|
1585
|
+
|
|
1586
|
+
tool_call_id = getattr(message, "tool_call_id", None)
|
|
1587
|
+
tool_name = tool_call_to_name.get(tool_call_id, "") or getattr(message, "name", "")
|
|
1588
|
+
tool_status = getattr(message, "status", "success")
|
|
1589
|
+
tool_content = format_tool_message_content(message.content)
|
|
1590
|
+
record = file_op_tracker.complete_with_message(message)
|
|
1591
|
+
|
|
1592
|
+
if is_subagent and namespace:
|
|
1593
|
+
if tool_status != "success" or (
|
|
1594
|
+
tool_content and str(tool_content).lower().startswith("error")
|
|
1595
|
+
):
|
|
1596
|
+
subagent_tracker.record_error(namespace, tool_name)
|
|
1597
|
+
# No live SubagentActivity("tool_result") event is emitted here — per-tool
|
|
1598
|
+
# cards clutter the UI. Errors are still recorded above for the completion
|
|
1599
|
+
# summary, and the dispatched/completed events carry the overall status.
|
|
1600
|
+
# The panel's preview does want each result, but only shows it on demand.
|
|
1601
|
+
if preview_task_id:
|
|
1602
|
+
# Sent even with no summary (file tools have none): it is what marks
|
|
1603
|
+
# the call as finished in the preview.
|
|
1604
|
+
_line = format_tool_result_preview(tool_name, tool_content, tool_status) or ""
|
|
1605
|
+
_failed = tool_status != "success" or _line.startswith("✗")
|
|
1606
|
+
yield ev.SubagentPreview(
|
|
1607
|
+
preview_task_id, "error" if _failed else "result", _line, tool_call_id or ""
|
|
1608
|
+
)
|
|
1609
|
+
|
|
1610
|
+
# Completed subagent (task tool)
|
|
1611
|
+
if tool_name == "task" and tool_call_id and tool_call_id in subagent_tracker.active_subagents:
|
|
1612
|
+
info = subagent_tracker.complete_subagent(tool_call_id)
|
|
1613
|
+
if info:
|
|
1614
|
+
subagent_type, description, start_time = info
|
|
1615
|
+
else:
|
|
1616
|
+
subagent_type, description, start_time = "unknown", "", time.time()
|
|
1617
|
+
activity = subagent_tracker.claim_namespace_for_tool_call(namespace, tool_call_id)
|
|
1618
|
+
yield ev.SubagentActivity(
|
|
1619
|
+
kind="completed",
|
|
1620
|
+
subagent_type=subagent_type,
|
|
1621
|
+
message=f"{get_status_icon(tool_status == 'success')} {subagent_type}",
|
|
1622
|
+
detail=(format_condensed_activity(activity) if activity else None),
|
|
1623
|
+
color=get_agent_color(subagent_type),
|
|
1624
|
+
call_id=tool_call_id,
|
|
1625
|
+
)
|
|
1626
|
+
# The panel's row for the same dispatch, now with its duration (the
|
|
1627
|
+
# tracker recorded the start and it was previously thrown away) and the
|
|
1628
|
+
# failure text, which the card only ever showed as a status icon.
|
|
1629
|
+
yield task_from_sync_completion(
|
|
1630
|
+
tool_call_id,
|
|
1631
|
+
now=time.time(),
|
|
1632
|
+
ok=tool_status == "success",
|
|
1633
|
+
error="" if tool_status == "success" else str(tool_content or "")[:400],
|
|
1634
|
+
started_at=start_time,
|
|
1635
|
+
subagent_type=subagent_type,
|
|
1636
|
+
description=description,
|
|
1637
|
+
)
|
|
1638
|
+
yield ev.StatusUpdate(
|
|
1639
|
+
status_phrases.status_line("synthesizing", agent_display_name)
|
|
1640
|
+
)
|
|
1641
|
+
return
|
|
1642
|
+
|
|
1643
|
+
if is_main_agent:
|
|
1644
|
+
# Surface shell / generic errors
|
|
1645
|
+
if tool_name == "shell" and tool_status != "success" and tool_content:
|
|
1646
|
+
for _e in flush_text():
|
|
1647
|
+
yield _e
|
|
1648
|
+
yield ev.ErrorOutput(tool_content)
|
|
1649
|
+
elif tool_content and isinstance(tool_content, str):
|
|
1650
|
+
if tool_content.lstrip().lower().startswith("error"):
|
|
1651
|
+
for _e in flush_text():
|
|
1652
|
+
yield _e
|
|
1653
|
+
yield ev.ErrorOutput(tool_content)
|
|
1654
|
+
|
|
1655
|
+
if record:
|
|
1656
|
+
for _e in flush_text():
|
|
1657
|
+
yield _e
|
|
1658
|
+
yield ev.FileOp(
|
|
1659
|
+
record=record,
|
|
1660
|
+
full_output=(tool_content if isinstance(tool_content, str) else ""),
|
|
1661
|
+
call_id=tool_call_id,
|
|
1662
|
+
)
|
|
1663
|
+
elif tool_call_id and tool_call_id in displayed_tool_ids:
|
|
1664
|
+
already_error = (tool_name == "shell" and tool_status != "success") or (
|
|
1665
|
+
tool_content
|
|
1666
|
+
and isinstance(tool_content, str)
|
|
1667
|
+
and tool_content.lstrip().lower().startswith("error")
|
|
1668
|
+
)
|
|
1669
|
+
if not already_error:
|
|
1670
|
+
elapsed = None
|
|
1671
|
+
start = subagent_tracker.tool_call_start_times.pop(tool_call_id, None)
|
|
1672
|
+
if start is not None:
|
|
1673
|
+
elapsed = time.time() - start
|
|
1674
|
+
preview = format_tool_result_preview(tool_name, tool_content, tool_status, elapsed)
|
|
1675
|
+
if preview:
|
|
1676
|
+
yield ev.ToolResult(
|
|
1677
|
+
preview=preview,
|
|
1678
|
+
is_error=preview.startswith("✗"),
|
|
1679
|
+
full_output=(tool_content if isinstance(tool_content, str) else ""),
|
|
1680
|
+
call_id=tool_call_id,
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
async def _handle_tool_call_chunk(
|
|
1685
|
+
block: dict,
|
|
1686
|
+
*,
|
|
1687
|
+
is_main_agent: bool,
|
|
1688
|
+
namespace,
|
|
1689
|
+
subagent_type_for_ns,
|
|
1690
|
+
tool_call_buffers: dict,
|
|
1691
|
+
displayed_tool_ids: set,
|
|
1692
|
+
tool_call_to_name: dict,
|
|
1693
|
+
file_op_tracker,
|
|
1694
|
+
subagent_tracker: SubagentTracker,
|
|
1695
|
+
flush_text,
|
|
1696
|
+
preview_task_id: str | None = None,
|
|
1697
|
+
) -> AsyncIterator[Any]:
|
|
1698
|
+
"""Accumulate streamed tool-call args and emit a ToolCall when complete."""
|
|
1699
|
+
chunk_name = block.get("name")
|
|
1700
|
+
chunk_args = block.get("args")
|
|
1701
|
+
chunk_id = block.get("id")
|
|
1702
|
+
chunk_index = block.get("index")
|
|
1703
|
+
|
|
1704
|
+
if chunk_index is not None:
|
|
1705
|
+
# Scoped to the agent the chunk came from: parallel subagents each count
|
|
1706
|
+
# their tool calls from index 0, and sharing a buffer interleaved their args.
|
|
1707
|
+
buffer_key: Any = (preview_task_id or namespace, chunk_index)
|
|
1708
|
+
elif chunk_id is not None:
|
|
1709
|
+
buffer_key = chunk_id
|
|
1710
|
+
else:
|
|
1711
|
+
buffer_key = f"unknown-{len(tool_call_buffers)}"
|
|
1712
|
+
|
|
1713
|
+
buffer = tool_call_buffers.setdefault(
|
|
1714
|
+
buffer_key, {"name": None, "id": None, "args": None, "args_parts": []}
|
|
1715
|
+
)
|
|
1716
|
+
if chunk_name:
|
|
1717
|
+
buffer["name"] = chunk_name
|
|
1718
|
+
if chunk_id:
|
|
1719
|
+
buffer["id"] = chunk_id
|
|
1720
|
+
|
|
1721
|
+
if isinstance(chunk_args, dict):
|
|
1722
|
+
buffer["args"] = chunk_args
|
|
1723
|
+
buffer["args_parts"] = []
|
|
1724
|
+
elif isinstance(chunk_args, str):
|
|
1725
|
+
if chunk_args:
|
|
1726
|
+
parts: list[str] = buffer.setdefault("args_parts", [])
|
|
1727
|
+
if not parts or chunk_args != parts[-1]:
|
|
1728
|
+
parts.append(chunk_args)
|
|
1729
|
+
buffer["args"] = "".join(parts)
|
|
1730
|
+
elif chunk_args is not None:
|
|
1731
|
+
buffer["args"] = chunk_args
|
|
1732
|
+
|
|
1733
|
+
buffer_name = buffer.get("name")
|
|
1734
|
+
buffer_id = buffer.get("id")
|
|
1735
|
+
if buffer_name is None:
|
|
1736
|
+
return
|
|
1737
|
+
|
|
1738
|
+
parsed_args = buffer.get("args")
|
|
1739
|
+
if isinstance(parsed_args, str):
|
|
1740
|
+
if not parsed_args:
|
|
1741
|
+
return
|
|
1742
|
+
try:
|
|
1743
|
+
parsed_args = json.loads(parsed_args)
|
|
1744
|
+
except json.JSONDecodeError:
|
|
1745
|
+
return
|
|
1746
|
+
elif parsed_args is None:
|
|
1747
|
+
return
|
|
1748
|
+
if not isinstance(parsed_args, dict):
|
|
1749
|
+
parsed_args = {"value": parsed_args}
|
|
1750
|
+
|
|
1751
|
+
for _e in flush_text():
|
|
1752
|
+
yield _e
|
|
1753
|
+
|
|
1754
|
+
display_needed = False
|
|
1755
|
+
if buffer_id is not None:
|
|
1756
|
+
if buffer_id not in displayed_tool_ids:
|
|
1757
|
+
displayed_tool_ids.add(buffer_id)
|
|
1758
|
+
tool_call_to_name[buffer_id] = buffer_name
|
|
1759
|
+
subagent_tracker.tool_call_start_times[buffer_id] = time.time()
|
|
1760
|
+
file_op_tracker.start_operation(buffer_name, parsed_args, buffer_id)
|
|
1761
|
+
display_needed = True
|
|
1762
|
+
else:
|
|
1763
|
+
file_op_tracker.update_args(buffer_id, parsed_args)
|
|
1764
|
+
else:
|
|
1765
|
+
display_needed = True
|
|
1766
|
+
tool_call_buffers.pop(buffer_key, None)
|
|
1767
|
+
|
|
1768
|
+
# A `task` call spawns a subagent — it's rendered via the SubagentActivity
|
|
1769
|
+
# ("dispatched"/"completed") events below, so suppress the duplicate generic
|
|
1770
|
+
# ToolCall card that would otherwise show the raw subagent prompt.
|
|
1771
|
+
_is_subagent_dispatch = buffer_name == "task" and "subagent_type" in parsed_args
|
|
1772
|
+
|
|
1773
|
+
# ask_question / ask_user_question are surfaced as a QuestionModal via their
|
|
1774
|
+
# "question" interrupt, so don't also render a generic tool card for them.
|
|
1775
|
+
_is_question = buffer_name in ("ask_question", "ask_user_question")
|
|
1776
|
+
|
|
1777
|
+
if display_needed and not _is_question and not _is_subagent_dispatch:
|
|
1778
|
+
icon = TOOL_ICONS.get(buffer_name, "🔧")
|
|
1779
|
+
display_str = format_tool_display(buffer_name, parsed_args)
|
|
1780
|
+
if is_main_agent:
|
|
1781
|
+
yield ev.ToolCall(
|
|
1782
|
+
name=buffer_name,
|
|
1783
|
+
display_str=display_str,
|
|
1784
|
+
icon=icon,
|
|
1785
|
+
is_main_agent=True,
|
|
1786
|
+
args=parsed_args,
|
|
1787
|
+
call_id=buffer_id,
|
|
1788
|
+
)
|
|
1789
|
+
elif namespace:
|
|
1790
|
+
# Track the subagent's tool call for the completion summary, but do
|
|
1791
|
+
# NOT emit a live SubagentActivity("tool_start") event — per-tool
|
|
1792
|
+
# cards clutter the UI. The dispatched/completed events are enough.
|
|
1793
|
+
subagent_tracker.record_tool_call(
|
|
1794
|
+
namespace,
|
|
1795
|
+
subagent_type_for_ns,
|
|
1796
|
+
buffer_name,
|
|
1797
|
+
parsed_args,
|
|
1798
|
+
TOOL_CATEGORIES,
|
|
1799
|
+
)
|
|
1800
|
+
if preview_task_id:
|
|
1801
|
+
yield ev.SubagentPreview(
|
|
1802
|
+
preview_task_id, "tool", f"{icon} {display_str}", buffer_id or ""
|
|
1803
|
+
)
|
|
1804
|
+
|
|
1805
|
+
if buffer_name == "task" and "subagent_type" in parsed_args:
|
|
1806
|
+
subagent_type = parsed_args["subagent_type"]
|
|
1807
|
+
description = parsed_args.get("description", "")
|
|
1808
|
+
if subagent_tracker.dispatch_subagent(buffer_id, subagent_type, description):
|
|
1809
|
+
yield ev.SubagentActivity(
|
|
1810
|
+
kind="dispatched",
|
|
1811
|
+
subagent_type=subagent_type,
|
|
1812
|
+
message=status_phrases.status_line("subagent", subagent_type),
|
|
1813
|
+
detail=description or None,
|
|
1814
|
+
color=get_agent_color(subagent_type),
|
|
1815
|
+
call_id=buffer_id,
|
|
1816
|
+
)
|
|
1817
|
+
# The panel's row for the same dispatch. A blocking task call is its
|
|
1818
|
+
# own one-row phase: it has no fan-out to belong to.
|
|
1819
|
+
yield task_from_sync_dispatch(
|
|
1820
|
+
buffer_id, subagent_type, description, time.time()
|
|
1821
|
+
)
|