iCode-TUI 0.29.1__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.
- chrys/__init__.py +16 -0
- chrys/app/__init__.py +3 -0
- chrys/app/acp/__init__.py +3 -0
- chrys/app/acp/bridge.py +664 -0
- chrys/app/acp/content.py +132 -0
- chrys/app/acp/doc/frontend-api.md +425 -0
- chrys/app/acp/history.py +585 -0
- chrys/app/acp/server.py +1796 -0
- chrys/app/acp/session_manager.py +1815 -0
- chrys/app/acp/tool_status.py +45 -0
- chrys/app/cli/__init__.py +3 -0
- chrys/app/cli/acp.py +178 -0
- chrys/app/cli/app.py +165 -0
- chrys/app/cli/approvals.py +101 -0
- chrys/app/cli/headless.py +223 -0
- chrys/app/cli/launch_cwd.py +42 -0
- chrys/app/cli/profiles.py +298 -0
- chrys/app/cli/progress.py +857 -0
- chrys/app/cli/run.py +425 -0
- chrys/app/cli/serve.py +1593 -0
- chrys/app/cli/tool_summary.py +225 -0
- chrys/app/cli/trajectory.py +201 -0
- chrys/app/cli/workflow.py +601 -0
- chrys/app/features/__init__.py +3 -0
- chrys/app/features/buddy/__init__.py +3 -0
- chrys/app/features/buddy/actions.py +68 -0
- chrys/app/features/buddy/animation.py +76 -0
- chrys/app/features/buddy/commands.py +196 -0
- chrys/app/features/buddy/docs/design.md +187 -0
- chrys/app/features/buddy/hatchery.py +80 -0
- chrys/app/features/buddy/lifecycle.py +41 -0
- chrys/app/features/buddy/model.py +265 -0
- chrys/app/features/buddy/pixel_renderer.py +113 -0
- chrys/app/features/buddy/pixel_sprites.py +279 -0
- chrys/app/features/buddy/portrait.py +76 -0
- chrys/app/features/buddy/progression.py +58 -0
- chrys/app/features/buddy/replies.py +191 -0
- chrys/app/features/buddy/sprites/alien.toml +70 -0
- chrys/app/features/buddy/sprites/axolotl.toml +71 -0
- chrys/app/features/buddy/sprites/bat.toml +69 -0
- chrys/app/features/buddy/sprites/bee.toml +69 -0
- chrys/app/features/buddy/sprites/cactus.toml +73 -0
- chrys/app/features/buddy/sprites/capybara.toml +70 -0
- chrys/app/features/buddy/sprites/cat.toml +69 -0
- chrys/app/features/buddy/sprites/crab.toml +70 -0
- chrys/app/features/buddy/sprites/dragon.toml +71 -0
- chrys/app/features/buddy/sprites/duck.toml +69 -0
- chrys/app/features/buddy/sprites/fox.toml +69 -0
- chrys/app/features/buddy/sprites/frog.toml +70 -0
- chrys/app/features/buddy/sprites/ghost.toml +70 -0
- chrys/app/features/buddy/sprites/goose.toml +69 -0
- chrys/app/features/buddy/sprites/jelly.toml +70 -0
- chrys/app/features/buddy/sprites/llama.toml +70 -0
- chrys/app/features/buddy/sprites/mushroom.toml +69 -0
- chrys/app/features/buddy/sprites/octopus.toml +70 -0
- chrys/app/features/buddy/sprites/owl.toml +70 -0
- chrys/app/features/buddy/sprites/panda.toml +71 -0
- chrys/app/features/buddy/sprites/penguin.toml +69 -0
- chrys/app/features/buddy/sprites/rabbit.toml +70 -0
- chrys/app/features/buddy/sprites/robot.toml +72 -0
- chrys/app/features/buddy/sprites/shark.toml +70 -0
- chrys/app/features/buddy/sprites/snail.toml +69 -0
- chrys/app/features/buddy/sprites/snake.toml +69 -0
- chrys/app/features/buddy/sprites/tabby.toml +69 -0
- chrys/app/features/buddy/sprites/turtle.toml +69 -0
- chrys/app/features/buddy/store.py +143 -0
- chrys/app/features/session_title/__init__.py +7 -0
- chrys/app/features/session_title/updater.py +582 -0
- chrys/app/installer.py +651 -0
- chrys/app/parsing.py +17 -0
- chrys/app/tui/__init__.py +3 -0
- chrys/app/tui/app.py +1287 -0
- chrys/app/tui/behaviors/__init__.py +3 -0
- chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
- chrys/app/tui/behaviors/insert_clipboard.py +73 -0
- chrys/app/tui/behaviors/right_click_copy.py +146 -0
- chrys/app/tui/binding_display.py +139 -0
- chrys/app/tui/buddy_messages.py +17 -0
- chrys/app/tui/buddy_reply.py +86 -0
- chrys/app/tui/chrys.tcss +549 -0
- chrys/app/tui/clipboard.py +109 -0
- chrys/app/tui/copy_messages.py +10 -0
- chrys/app/tui/i18n.py +230 -0
- chrys/app/tui/language.py +58 -0
- chrys/app/tui/notifications/__init__.py +12 -0
- chrys/app/tui/notifications/drivers.py +180 -0
- chrys/app/tui/notifications/service.py +126 -0
- chrys/app/tui/notifications/settings.py +141 -0
- chrys/app/tui/screens/__init__.py +3 -0
- chrys/app/tui/screens/agents/__init__.py +8 -0
- chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
- chrys/app/tui/screens/agents/config.py +2142 -0
- chrys/app/tui/screens/agents/config.tcss +169 -0
- chrys/app/tui/screens/agents/panels/__init__.py +3 -0
- chrys/app/tui/screens/agents/panels/acp.py +1176 -0
- chrys/app/tui/screens/agents/panels/basic.py +497 -0
- chrys/app/tui/screens/agents/panels/compaction.py +231 -0
- chrys/app/tui/screens/agents/panels/config_card.py +218 -0
- chrys/app/tui/screens/agents/panels/instructions.py +91 -0
- chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
- chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
- chrys/app/tui/screens/agents/panels/memory.py +684 -0
- chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
- chrys/app/tui/screens/agents/panels/skills.py +806 -0
- chrys/app/tui/screens/agents/panels/subagents.py +652 -0
- chrys/app/tui/screens/agents/panels/tools.py +205 -0
- chrys/app/tui/screens/agents/picker.py +103 -0
- chrys/app/tui/screens/agents/skill_paths.py +38 -0
- chrys/app/tui/screens/agents/validation_messages.py +452 -0
- chrys/app/tui/screens/dialogs/__init__.py +35 -0
- chrys/app/tui/screens/dialogs/agent_load.py +834 -0
- chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
- chrys/app/tui/screens/dialogs/app_mode.py +55 -0
- chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
- chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
- chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
- chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
- chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
- chrys/app/tui/screens/dialogs/approval/body.py +110 -0
- chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
- chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
- chrys/app/tui/screens/dialogs/ask_user.py +148 -0
- chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
- chrys/app/tui/screens/dialogs/base.py +84 -0
- chrys/app/tui/screens/dialogs/confirm.py +184 -0
- chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
- chrys/app/tui/screens/dialogs/connection_test.py +183 -0
- chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
- chrys/app/tui/screens/dialogs/diagram.py +102 -0
- chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
- chrys/app/tui/screens/dialogs/editor.py +378 -0
- chrys/app/tui/screens/dialogs/editor.tcss +132 -0
- chrys/app/tui/screens/dialogs/file_picker.py +582 -0
- chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
- chrys/app/tui/screens/dialogs/fork_session.py +219 -0
- chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
- chrys/app/tui/screens/dialogs/image_compression.py +102 -0
- chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
- chrys/app/tui/screens/dialogs/man_page.py +172 -0
- chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
- chrys/app/tui/screens/dialogs/new_folder.py +277 -0
- chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
- chrys/app/tui/screens/dialogs/notice.tcss +14 -0
- chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
- chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
- chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
- chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
- chrys/app/tui/screens/dialogs/session_title.py +110 -0
- chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
- chrys/app/tui/screens/dialogs/tool_view.py +207 -0
- chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
- chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
- chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
- chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
- chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
- chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
- chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
- chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
- chrys/app/tui/screens/diff/__init__.py +22 -0
- chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
- chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
- chrys/app/tui/screens/diff/screen.py +1532 -0
- chrys/app/tui/screens/diff/screen.tcss +104 -0
- chrys/app/tui/screens/guides/__init__.py +7 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
- chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/index.py +215 -0
- chrys/app/tui/screens/guides/reader.py +173 -0
- chrys/app/tui/screens/guides/screen.py +442 -0
- chrys/app/tui/screens/guides/screen.tcss +52 -0
- chrys/app/tui/screens/logs/__init__.py +7 -0
- chrys/app/tui/screens/logs/screen.py +504 -0
- chrys/app/tui/screens/logs/screen.tcss +45 -0
- chrys/app/tui/screens/main/__init__.py +7 -0
- chrys/app/tui/screens/main/action_availability.py +30 -0
- chrys/app/tui/screens/main/buddy_command.py +143 -0
- chrys/app/tui/screens/main/commands.py +1073 -0
- chrys/app/tui/screens/main/config_actions.py +464 -0
- chrys/app/tui/screens/main/copy_actions.py +117 -0
- chrys/app/tui/screens/main/dialog_controllers.py +826 -0
- chrys/app/tui/screens/main/dialog_gateway.py +166 -0
- chrys/app/tui/screens/main/diff_controller.py +478 -0
- chrys/app/tui/screens/main/engine_read_model.py +184 -0
- chrys/app/tui/screens/main/event_handlers.py +2014 -0
- chrys/app/tui/screens/main/input_flow.py +241 -0
- chrys/app/tui/screens/main/live_diff.py +244 -0
- chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
- chrys/app/tui/screens/main/model_indicator.py +217 -0
- chrys/app/tui/screens/main/navigation.py +343 -0
- chrys/app/tui/screens/main/ports.py +571 -0
- chrys/app/tui/screens/main/recent_dirs.py +123 -0
- chrys/app/tui/screens/main/rollback_controller.py +586 -0
- chrys/app/tui/screens/main/runtime_info.py +71 -0
- chrys/app/tui/screens/main/screen.py +2265 -0
- chrys/app/tui/screens/main/screen.tcss +21 -0
- chrys/app/tui/screens/main/session_handlers.py +1127 -0
- chrys/app/tui/screens/main/session_title.py +239 -0
- chrys/app/tui/screens/main/settings_coordinator.py +365 -0
- chrys/app/tui/screens/main/settings_persistence.py +184 -0
- chrys/app/tui/screens/main/shell_mode.py +112 -0
- chrys/app/tui/screens/main/state.py +289 -0
- chrys/app/tui/screens/main/submit_state.py +11 -0
- chrys/app/tui/screens/main/subscriptions.py +177 -0
- chrys/app/tui/screens/main/suggestions.py +982 -0
- chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
- chrys/app/tui/screens/main/view_adapter.py +1567 -0
- chrys/app/tui/screens/main/workflow_browser.py +747 -0
- chrys/app/tui/screens/main/workflow_content.py +248 -0
- chrys/app/tui/screens/main/workflow_controller.py +240 -0
- chrys/app/tui/screens/main/workflow_feedback.py +137 -0
- chrys/app/tui/screens/main/workflow_flow.py +49 -0
- chrys/app/tui/screens/main/workflow_run_control.py +456 -0
- chrys/app/tui/screens/main/workflow_session_view.py +316 -0
- chrys/app/tui/screens/main/workspace_actions.py +272 -0
- chrys/app/tui/screens/main/workspace_branch.py +194 -0
- chrys/app/tui/screens/menus/__init__.py +8 -0
- chrys/app/tui/screens/menus/languages.py +114 -0
- chrys/app/tui/screens/models/__init__.py +7 -0
- chrys/app/tui/screens/models/picker.py +147 -0
- chrys/app/tui/screens/models/screen.py +2043 -0
- chrys/app/tui/screens/models/screen.tcss +255 -0
- chrys/app/tui/screens/sessions/__init__.py +8 -0
- chrys/app/tui/screens/sessions/presenter.py +586 -0
- chrys/app/tui/screens/sessions/screen.py +779 -0
- chrys/app/tui/screens/sessions/screen.tcss +127 -0
- chrys/app/tui/screens/settings/__init__.py +29 -0
- chrys/app/tui/screens/settings/danger.py +26 -0
- chrys/app/tui/screens/settings/dialog.py +281 -0
- chrys/app/tui/screens/settings/layout.py +519 -0
- chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
- chrys/app/tui/screens/settings/panes/__init__.py +3 -0
- chrys/app/tui/screens/settings/panes/notifications.py +248 -0
- chrys/app/tui/screens/settings/panes/sessions.py +148 -0
- chrys/app/tui/screens/settings/ports.py +55 -0
- chrys/app/tui/screens/settings/provenance.py +102 -0
- chrys/app/tui/screens/settings/rows.py +546 -0
- chrys/app/tui/screens/settings/settings.tcss +406 -0
- chrys/app/tui/screens/themes/__init__.py +8 -0
- chrys/app/tui/screens/themes/dialogs.py +330 -0
- chrys/app/tui/screens/themes/editor.py +632 -0
- chrys/app/tui/screens/themes/editor.tcss +268 -0
- chrys/app/tui/screens/themes/messages.py +95 -0
- chrys/app/tui/screens/themes/palette.py +694 -0
- chrys/app/tui/screens/themes/panel.py +113 -0
- chrys/app/tui/screens/themes/picker.py +203 -0
- chrys/app/tui/screens/themes/save.py +191 -0
- chrys/app/tui/support/__init__.py +3 -0
- chrys/app/tui/support/file_manager.py +64 -0
- chrys/app/tui/support/gc_freeze.py +1114 -0
- chrys/app/tui/support/path_shortcuts.py +50 -0
- chrys/app/tui/support/prompt_history.py +298 -0
- chrys/app/tui/support/workspace_mru.py +476 -0
- chrys/app/tui/terminal/__init__.py +6 -0
- chrys/app/tui/terminal/_pty_backend.py +114 -0
- chrys/app/tui/terminal/emulator/__init__.py +24 -0
- chrys/app/tui/terminal/emulator/buffer.py +456 -0
- chrys/app/tui/terminal/emulator/charsets.py +113 -0
- chrys/app/tui/terminal/emulator/core.py +963 -0
- chrys/app/tui/terminal/emulator/input.py +342 -0
- chrys/app/tui/terminal/emulator/parser.py +227 -0
- chrys/app/tui/terminal/emulator/pen.py +158 -0
- chrys/app/tui/terminal/launcher.py +455 -0
- chrys/app/tui/terminal/leaked_reports.py +44 -0
- chrys/app/tui/terminal/panel.py +323 -0
- chrys/app/tui/terminal/pty_process.py +405 -0
- chrys/app/tui/terminal/rendering.py +151 -0
- chrys/app/tui/terminal/shell.py +273 -0
- chrys/app/tui/terminal/shell_integration.py +199 -0
- chrys/app/tui/terminal/title.py +224 -0
- chrys/app/tui/terminal/widget.py +654 -0
- chrys/app/tui/theme.py +422 -0
- chrys/app/tui/theme_loader.py +201 -0
- chrys/app/tui/theme_stylesheet.py +49 -0
- chrys/app/tui/themes/__init__.py +3 -0
- chrys/app/tui/themes/document.py +120 -0
- chrys/app/tui/themes/preview.py +170 -0
- chrys/app/tui/themes/store.py +176 -0
- chrys/app/tui/toasts.py +69 -0
- chrys/app/tui/util/__init__.py +3 -0
- chrys/app/tui/util/context_pressure.py +146 -0
- chrys/app/tui/util/debug_log.py +292 -0
- chrys/app/tui/util/diff_entries.py +64 -0
- chrys/app/tui/util/formatting.py +53 -0
- chrys/app/tui/util/git_branch.py +326 -0
- chrys/app/tui/util/invocation_progress.py +57 -0
- chrys/app/tui/util/logo.py +32 -0
- chrys/app/tui/util/message_gate.py +35 -0
- chrys/app/tui/util/mutation_sources.py +14 -0
- chrys/app/tui/util/picker_text.py +27 -0
- chrys/app/tui/util/removal.py +47 -0
- chrys/app/tui/util/rich_style.py +36 -0
- chrys/app/tui/util/source_text.py +12 -0
- chrys/app/tui/util/static_update.py +88 -0
- chrys/app/tui/util/todo_format.py +51 -0
- chrys/app/tui/util/visibility.py +214 -0
- chrys/app/tui/widgets/__init__.py +60 -0
- chrys/app/tui/widgets/ask_user_controls.py +652 -0
- chrys/app/tui/widgets/ask_user_markdown.py +17 -0
- chrys/app/tui/widgets/ask_user_prompt.py +606 -0
- chrys/app/tui/widgets/buttons.py +52 -0
- chrys/app/tui/widgets/chat/__init__.py +3 -0
- chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
- chrys/app/tui/widgets/chat/chrome.py +296 -0
- chrys/app/tui/widgets/chat/compaction_card.py +353 -0
- chrys/app/tui/widgets/chat/context_fold.py +77 -0
- chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
- chrys/app/tui/widgets/chat/image_preview.py +214 -0
- chrys/app/tui/widgets/chat/live_renderer.py +383 -0
- chrys/app/tui/widgets/chat/messages.py +1039 -0
- chrys/app/tui/widgets/chat/panel.py +1769 -0
- chrys/app/tui/widgets/chat/ports.py +245 -0
- chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
- chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
- chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
- chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
- chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
- chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
- chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
- chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
- chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
- chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
- chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
- chrys/app/tui/widgets/chat/renderers/search.py +348 -0
- chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
- chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
- chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
- chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
- chrys/app/tui/widgets/chat/renderers/web.py +69 -0
- chrys/app/tui/widgets/chat/replay.py +1605 -0
- chrys/app/tui/widgets/chat/replay_mount.py +322 -0
- chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
- chrys/app/tui/widgets/chat/selection_controller.py +435 -0
- chrys/app/tui/widgets/chat/session_json.py +508 -0
- chrys/app/tui/widgets/chat/toc_model.py +156 -0
- chrys/app/tui/widgets/chat/tool_call.py +2478 -0
- chrys/app/tui/widgets/chat/tool_registry.py +753 -0
- chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
- chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
- chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
- chrys/app/tui/widgets/checkbox.py +24 -0
- chrys/app/tui/widgets/chrome/__init__.py +3 -0
- chrys/app/tui/widgets/chrome/app_header.py +283 -0
- chrys/app/tui/widgets/chrome/commands.py +95 -0
- chrys/app/tui/widgets/chrome/file_index.py +68 -0
- chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
- chrys/app/tui/widgets/chrome/footer.py +284 -0
- chrys/app/tui/widgets/chrome/image_paste.py +351 -0
- chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
- chrys/app/tui/widgets/chrome/status_bar.py +911 -0
- chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
- chrys/app/tui/widgets/click_affordance.py +24 -0
- chrys/app/tui/widgets/color_picker/__init__.py +254 -0
- chrys/app/tui/widgets/color_picker/controls.py +171 -0
- chrys/app/tui/widgets/color_picker/model.py +89 -0
- chrys/app/tui/widgets/dialog_buttons.py +58 -0
- chrys/app/tui/widgets/dialog_scroll.py +24 -0
- chrys/app/tui/widgets/diff_view/__init__.py +20 -0
- chrys/app/tui/widgets/diff_view/cells.py +59 -0
- chrys/app/tui/widgets/diff_view/code.py +280 -0
- chrys/app/tui/widgets/diff_view/compute.py +286 -0
- chrys/app/tui/widgets/diff_view/gutter.py +161 -0
- chrys/app/tui/widgets/diff_view/inline.py +77 -0
- chrys/app/tui/widgets/diff_view/palette.py +87 -0
- chrys/app/tui/widgets/diff_view/protocols.py +24 -0
- chrys/app/tui/widgets/diff_view/rows.py +138 -0
- chrys/app/tui/widgets/diff_view/unified.py +267 -0
- chrys/app/tui/widgets/diff_view/widget.py +209 -0
- chrys/app/tui/widgets/editor/__init__.py +79 -0
- chrys/app/tui/widgets/editor/coordinates.py +30 -0
- chrys/app/tui/widgets/editor/highlighter.py +139 -0
- chrys/app/tui/widgets/editor/keymaps.py +357 -0
- chrys/app/tui/widgets/editor/types.py +216 -0
- chrys/app/tui/widgets/editor/vim.py +1195 -0
- chrys/app/tui/widgets/editor/widget.py +401 -0
- chrys/app/tui/widgets/file_preview.py +58 -0
- chrys/app/tui/widgets/hatch.py +138 -0
- chrys/app/tui/widgets/input.py +66 -0
- chrys/app/tui/widgets/loading.py +125 -0
- chrys/app/tui/widgets/markdown/__init__.py +25 -0
- chrys/app/tui/widgets/markdown/_utils.py +21 -0
- chrys/app/tui/widgets/markdown/anchors.py +71 -0
- chrys/app/tui/widgets/markdown/blocks.py +115 -0
- chrys/app/tui/widgets/markdown/copyable.py +163 -0
- chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
- chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
- chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
- chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
- chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
- chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
- chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
- chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
- chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
- chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
- chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
- chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
- chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
- chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
- chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
- chrys/app/tui/widgets/markdown/links.py +47 -0
- chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
- chrys/app/tui/widgets/markdown/math/layout.py +739 -0
- chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
- chrys/app/tui/widgets/markdown/math/parser.py +325 -0
- chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
- chrys/app/tui/widgets/markdown/navigator.py +80 -0
- chrys/app/tui/widgets/markdown/parser.py +1057 -0
- chrys/app/tui/widgets/markdown/stream.py +73 -0
- chrys/app/tui/widgets/markdown/toc.py +98 -0
- chrys/app/tui/widgets/markdown/viewer.py +146 -0
- chrys/app/tui/widgets/markdown/widget.py +2046 -0
- chrys/app/tui/widgets/marquee.py +101 -0
- chrys/app/tui/widgets/option_menu.py +82 -0
- chrys/app/tui/widgets/page_navigator.py +118 -0
- chrys/app/tui/widgets/select.py +196 -0
- chrys/app/tui/widgets/selection.py +59 -0
- chrys/app/tui/widgets/sidebar/__init__.py +3 -0
- chrys/app/tui/widgets/sidebar/buddy.py +367 -0
- chrys/app/tui/widgets/sidebar/context.py +482 -0
- chrys/app/tui/widgets/sidebar/debug.py +195 -0
- chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
- chrys/app/tui/widgets/sidebar/panel.py +198 -0
- chrys/app/tui/widgets/sidebar/tasks.py +198 -0
- chrys/app/tui/widgets/sidebar/toc.py +166 -0
- chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
- chrys/app/tui/widgets/syntax_theme.py +85 -0
- chrys/app/tui/widgets/text_area.py +258 -0
- chrys/app/tui/widgets/trajectory/__init__.py +8 -0
- chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
- chrys/app/tui/widgets/trajectory/insights.py +1266 -0
- chrys/app/tui/widgets/trajectory/overview.py +1101 -0
- chrys/app/tui/widgets/trajectory/panel.py +983 -0
- chrys/app/tui/widgets/trajectory/presentation.py +311 -0
- chrys/app/tui/widgets/trajectory/session_info.py +124 -0
- chrys/app/tui/widgets/trajectory/text_view.py +124 -0
- chrys/app/tui/widgets/trajectory/timeline.py +380 -0
- chrys/app/tui/widgets/welcome.py +126 -0
- chrys/app/tui/widgets/workflow/__init__.py +5 -0
- chrys/app/tui/widgets/workflow/edges.py +89 -0
- chrys/app/tui/widgets/workflow/graph.py +869 -0
- chrys/app/tui/widgets/workflow/info.py +310 -0
- chrys/app/tui/widgets/workflow/node_view.py +27 -0
- chrys/app/tui/widgets/workflow/output.py +225 -0
- chrys/app/tui/widgets/workflow/panel.py +658 -0
- chrys/app/tui/widgets/workflow/projector.py +324 -0
- chrys/app/tui/widgets/workflow/records.py +69 -0
- chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
- chrys/app/tui/widgets/workflow/selection.py +151 -0
- chrys/app/tui/widgets/workflow/source.py +65 -0
- chrys/app/tui/widgets/workflow/text.py +257 -0
- chrys/app/tui/widgets/workflow/values.py +317 -0
- chrys/foundation/__init__.py +0 -0
- chrys/foundation/branding.py +15 -0
- chrys/foundation/config/__init__.py +7 -0
- chrys/foundation/config/coercion.py +374 -0
- chrys/foundation/config/context.py +34 -0
- chrys/foundation/config/env_file.py +141 -0
- chrys/foundation/config/env_layers.py +370 -0
- chrys/foundation/config/migrations.py +521 -0
- chrys/foundation/config/notification_events.py +22 -0
- chrys/foundation/config/process_settings.py +332 -0
- chrys/foundation/config/runtime_pointer.py +133 -0
- chrys/foundation/config/settings.py +1952 -0
- chrys/foundation/config/settings_store.py +996 -0
- chrys/foundation/config/spec.py +396 -0
- chrys/foundation/config/user_settings.py +140 -0
- chrys/foundation/config/warnings.py +262 -0
- chrys/foundation/config/web_values.py +135 -0
- chrys/foundation/config/yaml_store.py +487 -0
- chrys/foundation/errors/__init__.py +68 -0
- chrys/foundation/errors/_legacy.py +117 -0
- chrys/foundation/errors/_text.py +13 -0
- chrys/foundation/errors/_walk.py +85 -0
- chrys/foundation/errors/classify.py +278 -0
- chrys/foundation/errors/display.py +274 -0
- chrys/foundation/errors/formatting.py +283 -0
- chrys/foundation/errors/kinds.py +67 -0
- chrys/foundation/errors/network.py +497 -0
- chrys/foundation/errors/provider.py +445 -0
- chrys/foundation/errors/route.py +85 -0
- chrys/foundation/events/__init__.py +8 -0
- chrys/foundation/events/bus.py +143 -0
- chrys/foundation/events/types.py +1629 -0
- chrys/foundation/events/workflow.py +38 -0
- chrys/foundation/hosted_tools.py +142 -0
- chrys/foundation/i18n/__init__.py +32 -0
- chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
- chrys/foundation/i18n/catalogs.py +42 -0
- chrys/foundation/i18n/formatting.py +202 -0
- chrys/foundation/i18n/locale.py +92 -0
- chrys/foundation/i18n/localizer.py +187 -0
- chrys/foundation/i18n/messages.py +268 -0
- chrys/foundation/io/__init__.py +3 -0
- chrys/foundation/io/result_content.py +237 -0
- chrys/foundation/models/__init__.py +3 -0
- chrys/foundation/models/approval_reuse.py +23 -0
- chrys/foundation/models/ask_user.py +468 -0
- chrys/foundation/models/execution.py +18 -0
- chrys/foundation/models/history_markers.py +115 -0
- chrys/foundation/models/invocations.py +63 -0
- chrys/foundation/models/mutation_scope.py +28 -0
- chrys/foundation/models/session_env.py +87 -0
- chrys/foundation/models/session_surface.py +29 -0
- chrys/foundation/models/todos.py +74 -0
- chrys/foundation/models/turns.py +125 -0
- chrys/foundation/models/workflow_session.py +89 -0
- chrys/foundation/models/workspace.py +59 -0
- chrys/foundation/net/__init__.py +3 -0
- chrys/foundation/net/address_class.py +57 -0
- chrys/foundation/net/route_probe.py +94 -0
- chrys/foundation/net/url.py +107 -0
- chrys/foundation/observability/__init__.py +3 -0
- chrys/foundation/observability/exporters.py +174 -0
- chrys/foundation/observability/gate.py +31 -0
- chrys/foundation/observability/setup.py +497 -0
- chrys/foundation/observability/sink.py +228 -0
- chrys/foundation/patches/__init__.py +19 -0
- chrys/foundation/patches/mdurl_cache.py +59 -0
- chrys/foundation/patches/patcher.py +287 -0
- chrys/foundation/patches/staged_members.py +205 -0
- chrys/foundation/patches/textual_block_border.py +105 -0
- chrys/foundation/patches/textual_button_ansi.py +65 -0
- chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
- chrys/foundation/patches/textual_callback_cache.py +248 -0
- chrys/foundation/patches/textual_callback_dispatch.py +109 -0
- chrys/foundation/patches/textual_compositor_cjk.py +566 -0
- chrys/foundation/patches/textual_dispatch_cache.py +150 -0
- chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
- chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
- chrys/foundation/patches/textual_lru_acyclic.py +241 -0
- chrys/foundation/patches/textual_message_pump.py +136 -0
- chrys/foundation/patches/textual_node_diet.py +875 -0
- chrys/foundation/patches/textual_one_shot_timer.py +48 -0
- chrys/foundation/patches/textual_option_list.py +90 -0
- chrys/foundation/patches/textual_precompose.py +89 -0
- chrys/foundation/patches/textual_pruned_tabs.py +69 -0
- chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
- chrys/foundation/patches/textual_removed_node_caches.py +86 -0
- chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
- chrys/foundation/patches/textual_selection_extract.py +48 -0
- chrys/foundation/patches/textual_strip_cycles.py +119 -0
- chrys/foundation/patches/textual_tab_selection.py +1611 -0
- chrys/foundation/patches/textual_timer_skip.py +70 -0
- chrys/foundation/patches/textual_utf8_decoder.py +106 -0
- chrys/foundation/patches/textual_win_sleep.py +167 -0
- chrys/foundation/patches/watchdog_fsevents.py +70 -0
- chrys/foundation/patches/watchdog_windows.py +52 -0
- chrys/foundation/platform/__init__.py +556 -0
- chrys/foundation/platform/c_api.py +32 -0
- chrys/foundation/platform/child_reap.py +107 -0
- chrys/foundation/platform/command_line.py +95 -0
- chrys/foundation/platform/files.py +1470 -0
- chrys/foundation/platform/output_capture.py +113 -0
- chrys/foundation/platform/paths.py +193 -0
- chrys/foundation/platform/process.py +1468 -0
- chrys/foundation/platform/pty_output.py +19 -0
- chrys/foundation/platform/runtime_paths.py +281 -0
- chrys/foundation/platform/win_console.py +129 -0
- chrys/foundation/reasoning_origin.py +67 -0
- chrys/foundation/recovery.py +16 -0
- chrys/foundation/retry.py +278 -0
- chrys/foundation/text/__init__.py +3 -0
- chrys/foundation/text/encoding.py +603 -0
- chrys/foundation/text/images.py +304 -0
- chrys/foundation/text/lines.py +24 -0
- chrys/foundation/text/mentions.py +90 -0
- chrys/foundation/text/model_json.py +25 -0
- chrys/foundation/text/reading_order.py +20 -0
- chrys/foundation/text/tokenizer.py +25 -0
- chrys/foundation/text/tool_output.py +287 -0
- chrys/foundation/text/yaml_io.py +62 -0
- chrys/foundation/tool_call_context.py +202 -0
- chrys/foundation/tool_execution_stamp.py +184 -0
- chrys/foundation/tool_invocation_order.py +48 -0
- chrys/foundation/tool_kinds.py +102 -0
- chrys/foundation/tool_result_metadata.py +287 -0
- chrys/foundation/trajectory/__init__.py +10 -0
- chrys/foundation/trajectory/context.py +481 -0
- chrys/foundation/trajectory/envelope.py +571 -0
- chrys/foundation/trajectory/event_types.py +294 -0
- chrys/foundation/trajectory/finalizers.py +46 -0
- chrys/foundation/trajectory/fingerprint.py +62 -0
- chrys/foundation/trajectory/ids.py +155 -0
- chrys/foundation/trajectory/keys.py +99 -0
- chrys/foundation/trajectory/lease.py +173 -0
- chrys/foundation/trajectory/metadata.py +72 -0
- chrys/foundation/trajectory/reader.py +271 -0
- chrys/foundation/trajectory/recovery.py +225 -0
- chrys/foundation/trajectory/revisions.py +152 -0
- chrys/foundation/trajectory/segments.py +225 -0
- chrys/foundation/trajectory/tools.py +117 -0
- chrys/foundation/trajectory/usage.py +151 -0
- chrys/foundation/trajectory/writer.py +972 -0
- chrys/foundation/trajectory_timing.py +97 -0
- chrys/foundation/util/__init__.py +3 -0
- chrys/foundation/util/async_tasks.py +29 -0
- chrys/foundation/util/chrys_headers.py +23 -0
- chrys/foundation/util/env_templates.py +104 -0
- chrys/foundation/util/filenames.py +96 -0
- chrys/foundation/util/header_charset.py +138 -0
- chrys/foundation/util/httpx_helpers.py +12 -0
- chrys/foundation/util/lock.py +236 -0
- chrys/foundation/util/once_close.py +46 -0
- chrys/foundation/util/session_ids.py +29 -0
- chrys/foundation/util/shell_tokens.py +88 -0
- chrys/foundation/util/sub_agent_context.py +38 -0
- chrys/foundation/util/time.py +35 -0
- chrys/foundation/util/unicode_scalars.py +36 -0
- chrys/foundation/vendor/__init__.py +43 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
- chrys/kernel/__init__.py +256 -0
- chrys/kernel/_content.py +1866 -0
- chrys/kernel/_loop_recorder.py +631 -0
- chrys/kernel/_null_overlay.py +1026 -0
- chrys/kernel/_result_ceiling.py +216 -0
- chrys/kernel/_serialization.py +431 -0
- chrys/kernel/_tool_arg_errors.py +1629 -0
- chrys/kernel/_tool_execution.py +1172 -0
- chrys/kernel/_tool_expansion.py +26 -0
- chrys/kernel/_types.py +2598 -0
- chrys/kernel/agent.py +994 -0
- chrys/kernel/client.py +1084 -0
- chrys/kernel/compaction.py +1315 -0
- chrys/kernel/exceptions.py +211 -0
- chrys/kernel/exchanges.py +683 -0
- chrys/kernel/identity.py +95 -0
- chrys/kernel/images.py +36 -0
- chrys/kernel/instrumentation.py +1618 -0
- chrys/kernel/loop.py +2308 -0
- chrys/kernel/middleware.py +616 -0
- chrys/kernel/sessions.py +604 -0
- chrys/kernel/tools.py +1061 -0
- chrys/kernel/types.py +126 -0
- chrys/orchestration/__init__.py +3 -0
- chrys/orchestration/engine/__init__.py +3 -0
- chrys/orchestration/engine/assembly.py +236 -0
- chrys/orchestration/engine/build/__init__.py +3 -0
- chrys/orchestration/engine/build/builder.py +1061 -0
- chrys/orchestration/engine/build/construction.py +408 -0
- chrys/orchestration/engine/build/loaded.py +139 -0
- chrys/orchestration/engine/engine.py +789 -0
- chrys/orchestration/engine/execution.py +771 -0
- chrys/orchestration/engine/loader.py +865 -0
- chrys/orchestration/engine/rollback.py +918 -0
- chrys/orchestration/engine/run/__init__.py +3 -0
- chrys/orchestration/engine/run/active_injection.py +419 -0
- chrys/orchestration/engine/run/attachments.py +519 -0
- chrys/orchestration/engine/run/bindings.py +1095 -0
- chrys/orchestration/engine/run/coordinator.py +1062 -0
- chrys/orchestration/engine/run/finalizer.py +417 -0
- chrys/orchestration/engine/run/input_refs.py +60 -0
- chrys/orchestration/engine/run/prompt_content.py +457 -0
- chrys/orchestration/engine/run/resume.py +299 -0
- chrys/orchestration/engine/run/retry.py +948 -0
- chrys/orchestration/engine/run/runner.py +625 -0
- chrys/orchestration/engine/run/runtime_skills.py +146 -0
- chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
- chrys/orchestration/engine/run/turn_hooks.py +258 -0
- chrys/orchestration/engine/run/turn_state.py +149 -0
- chrys/orchestration/engine/run/working_dir.py +50 -0
- chrys/orchestration/engine/session_lifecycle.py +2013 -0
- chrys/orchestration/engine/state/__init__.py +3 -0
- chrys/orchestration/engine/state/active_session.py +183 -0
- chrys/orchestration/engine/state/controls.py +639 -0
- chrys/orchestration/engine/state/current_agent.py +26 -0
- chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
- chrys/orchestration/engine/state/machine.py +193 -0
- chrys/orchestration/engine/state/session_writer.py +434 -0
- chrys/orchestration/engine/trajectory.py +714 -0
- chrys/orchestration/engine/usage.py +118 -0
- chrys/orchestration/invoker/__init__.py +3 -0
- chrys/orchestration/invoker/acp.py +727 -0
- chrys/orchestration/invoker/acp_protocol.py +1115 -0
- chrys/orchestration/invoker/acp_spec.py +79 -0
- chrys/orchestration/invoker/attempts.py +994 -0
- chrys/orchestration/invoker/child_compaction.py +131 -0
- chrys/orchestration/invoker/child_history.py +99 -0
- chrys/orchestration/invoker/contracts.py +253 -0
- chrys/orchestration/invoker/evidence.py +94 -0
- chrys/orchestration/invoker/kernel.py +343 -0
- chrys/orchestration/invoker/origin.py +79 -0
- chrys/orchestration/invoker/resources.py +343 -0
- chrys/orchestration/invoker/runtime.py +299 -0
- chrys/orchestration/invoker/usage.py +80 -0
- chrys/orchestration/session_hooks.py +117 -0
- chrys/orchestration/session_host.py +1023 -0
- chrys/orchestration/session_resources.py +95 -0
- chrys/orchestration/session_usage.py +219 -0
- chrys/orchestration/startup.py +255 -0
- chrys/orchestration/sub_agents/__init__.py +3 -0
- chrys/orchestration/sub_agents/acp_policy.py +269 -0
- chrys/orchestration/sub_agents/kernel_policy.py +848 -0
- chrys/orchestration/sub_agents/registration.py +44 -0
- chrys/orchestration/sub_agents/shell.py +337 -0
- chrys/orchestration/sub_agents/tools.py +2228 -0
- chrys/orchestration/workflows/__init__.py +3 -0
- chrys/orchestration/workflows/agent_archive.py +156 -0
- chrys/orchestration/workflows/agent_node.py +894 -0
- chrys/orchestration/workflows/agent_node_build.py +687 -0
- chrys/orchestration/workflows/catalog.py +332 -0
- chrys/orchestration/workflows/coordinator.py +771 -0
- chrys/orchestration/workflows/hooks.py +176 -0
- chrys/orchestration/workflows/preview.py +322 -0
- chrys/orchestration/workflows/rollback.py +151 -0
- chrys/orchestration/workflows/runner.py +986 -0
- chrys/orchestration/workflows/session.py +390 -0
- chrys/orchestration/workflows/settings.py +97 -0
- chrys/orchestration/workflows/validation.py +674 -0
- chrys/orchestration/workflows/worker_client.py +1002 -0
- chrys/service/__init__.py +3 -0
- chrys/service/acp_client/__init__.py +65 -0
- chrys/service/acp_client/_transport.py +922 -0
- chrys/service/acp_client/client.py +1311 -0
- chrys/service/acp_client/errors.py +224 -0
- chrys/service/acp_client/protocol.py +220 -0
- chrys/service/acp_client/spawn.py +239 -0
- chrys/service/acp_client/spec.py +100 -0
- chrys/service/agent_middleware/__init__.py +60 -0
- chrys/service/agent_middleware/_metadata_keys.py +52 -0
- chrys/service/agent_middleware/control/__init__.py +3 -0
- chrys/service/agent_middleware/control/approval.py +769 -0
- chrys/service/agent_middleware/control/approval_hooks.py +62 -0
- chrys/service/agent_middleware/control/ask_user.py +207 -0
- chrys/service/agent_middleware/control/interrupt.py +50 -0
- chrys/service/agent_middleware/control/sleep.py +118 -0
- chrys/service/agent_middleware/control/todo.py +117 -0
- chrys/service/agent_middleware/events/__init__.py +3 -0
- chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
- chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
- chrys/service/agent_middleware/events/intermediate_text.py +128 -0
- chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
- chrys/service/agent_middleware/events/result_persistence.py +98 -0
- chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
- chrys/service/agent_middleware/events/tool_error_text.py +22 -0
- chrys/service/agent_middleware/events/tool_events.py +779 -0
- chrys/service/agent_middleware/injection.py +510 -0
- chrys/service/agent_middleware/reminders/__init__.py +91 -0
- chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
- chrys/service/agent_middleware/reminders/base.py +25 -0
- chrys/service/agent_middleware/reminders/context_usage.py +74 -0
- chrys/service/agent_middleware/reminders/file_change.py +63 -0
- chrys/service/agent_middleware/reminders/mcp.py +39 -0
- chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
- chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
- chrys/service/agent_middleware/reminders/skills.py +44 -0
- chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
- chrys/service/agent_middleware/reminders/todo.py +35 -0
- chrys/service/agent_middleware/reminders/turn_line.py +48 -0
- chrys/service/agent_middleware/response_validation.py +1380 -0
- chrys/service/agent_middleware/system_reminder.py +1107 -0
- chrys/service/agent_middleware/validators.py +396 -0
- chrys/service/analytics/__init__.py +124 -0
- chrys/service/analytics/_actions.py +896 -0
- chrys/service/analytics/_context_evidence.py +357 -0
- chrys/service/analytics/_critical_path.py +179 -0
- chrys/service/analytics/_facts.py +919 -0
- chrys/service/analytics/_insights.py +769 -0
- chrys/service/analytics/_metric_ops.py +83 -0
- chrys/service/analytics/_session_projection.py +460 -0
- chrys/service/analytics/_timeline.py +434 -0
- chrys/service/analytics/_turn_graph.py +1237 -0
- chrys/service/analytics/_turns.py +1377 -0
- chrys/service/analytics/_workflows.py +171 -0
- chrys/service/analytics/aggregation.py +1474 -0
- chrys/service/analytics/classification.py +128 -0
- chrys/service/analytics/export.py +803 -0
- chrys/service/analytics/findings.py +282 -0
- chrys/service/analytics/math.py +79 -0
- chrys/service/analytics/model.py +886 -0
- chrys/service/analytics/reader.py +321 -0
- chrys/service/approval/__init__.py +3 -0
- chrys/service/approval/arbitration.py +102 -0
- chrys/service/approval/command_identity.py +109 -0
- chrys/service/approval/correlation.py +57 -0
- chrys/service/approval/grant_store.py +108 -0
- chrys/service/approval/judge.py +589 -0
- chrys/service/approval/level.py +15 -0
- chrys/service/approval/policy.py +209 -0
- chrys/service/approval/reuse.py +223 -0
- chrys/service/approval/reuse_binding.py +133 -0
- chrys/service/approval/safety_classifier.py +536 -0
- chrys/service/approval/turn_context.py +49 -0
- chrys/service/context/__init__.py +3 -0
- chrys/service/context/compaction/__init__.py +38 -0
- chrys/service/context/compaction/breaker.py +156 -0
- chrys/service/context/compaction/budgets.py +93 -0
- chrys/service/context/compaction/calibration.py +77 -0
- chrys/service/context/compaction/compression.py +690 -0
- chrys/service/context/compaction/current_turn_drop.py +458 -0
- chrys/service/context/compaction/events.py +201 -0
- chrys/service/context/compaction/exclusions.py +261 -0
- chrys/service/context/compaction/groups.py +361 -0
- chrys/service/context/compaction/last_words.py +1996 -0
- chrys/service/context/compaction/last_words_state.py +752 -0
- chrys/service/context/compaction/scoped.py +472 -0
- chrys/service/context/compaction/spill.py +1947 -0
- chrys/service/context/compaction/strategy.py +1116 -0
- chrys/service/context/compaction/summaries.py +319 -0
- chrys/service/context/compaction/turns.py +375 -0
- chrys/service/context/manager.py +174 -0
- chrys/service/context/memory_loader.py +792 -0
- chrys/service/context/middleware/__init__.py +3 -0
- chrys/service/context/middleware/usage.py +332 -0
- chrys/service/context/providers/__init__.py +3 -0
- chrys/service/context/providers/context_management.py +432 -0
- chrys/service/context/providers/history.py +647 -0
- chrys/service/context/providers/memory.py +50 -0
- chrys/service/context/tool_content.py +22 -0
- chrys/service/hooks/__init__.py +37 -0
- chrys/service/hooks/detached_worker.py +118 -0
- chrys/service/hooks/docs/README.md +14 -0
- chrys/service/hooks/docs/authoring.md +684 -0
- chrys/service/hooks/docs/configuration.md +499 -0
- chrys/service/hooks/docs/design.md +359 -0
- chrys/service/hooks/docs/project-hooks.md +348 -0
- chrys/service/hooks/events.py +157 -0
- chrys/service/hooks/loader.py +665 -0
- chrys/service/hooks/manager.py +807 -0
- chrys/service/hooks/matcher.py +111 -0
- chrys/service/hooks/outbox.py +294 -0
- chrys/service/hooks/runner.py +572 -0
- chrys/service/hooks/schema.py +407 -0
- chrys/service/llm/__init__.py +7 -0
- chrys/service/llm/_structured_outputs.py +192 -0
- chrys/service/llm/anthropic_messages/__init__.py +9 -0
- chrys/service/llm/anthropic_messages/client.py +254 -0
- chrys/service/llm/anthropic_messages/decode.py +331 -0
- chrys/service/llm/anthropic_messages/history.py +279 -0
- chrys/service/llm/anthropic_messages/request.py +323 -0
- chrys/service/llm/anthropic_messages/server_tools.py +319 -0
- chrys/service/llm/anthropic_messages/stream.py +297 -0
- chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
- chrys/service/llm/chat_completions/__init__.py +9 -0
- chrys/service/llm/chat_completions/client.py +369 -0
- chrys/service/llm/chat_completions/decode.py +338 -0
- chrys/service/llm/chat_completions/history.py +657 -0
- chrys/service/llm/chat_completions/reasoning.py +244 -0
- chrys/service/llm/chat_completions/request.py +192 -0
- chrys/service/llm/chat_completions/stream.py +546 -0
- chrys/service/llm/chat_completions/validation.py +308 -0
- chrys/service/llm/clients.py +613 -0
- chrys/service/llm/images.py +65 -0
- chrys/service/llm/mock.py +413 -0
- chrys/service/llm/observer.py +446 -0
- chrys/service/llm/one_shot.py +106 -0
- chrys/service/llm/openai_exceptions.py +93 -0
- chrys/service/llm/openai_responses/__init__.py +10 -0
- chrys/service/llm/openai_responses/client.py +323 -0
- chrys/service/llm/openai_responses/decode.py +432 -0
- chrys/service/llm/openai_responses/history.py +519 -0
- chrys/service/llm/openai_responses/hosted.py +625 -0
- chrys/service/llm/openai_responses/replay.py +629 -0
- chrys/service/llm/openai_responses/request.py +342 -0
- chrys/service/llm/openai_responses/stream.py +847 -0
- chrys/service/llm/openai_timestamps.py +57 -0
- chrys/service/llm/providers.py +107 -0
- chrys/service/llm/proxy_route.py +54 -0
- chrys/service/llm/raw_http_log.py +309 -0
- chrys/service/llm/route_facts.py +63 -0
- chrys/service/llm/route_sessions.py +49 -0
- chrys/service/llm/sdk_anthropic.py +18 -0
- chrys/service/llm/sdk_headers.py +47 -0
- chrys/service/llm/sdk_openai.py +18 -0
- chrys/service/llm/sdk_retry.py +59 -0
- chrys/service/llm/wire_client.py +246 -0
- chrys/service/mcp/__init__.py +3 -0
- chrys/service/mcp/_connection.py +235 -0
- chrys/service/mcp/_http_transport.py +350 -0
- chrys/service/mcp/_progressive.py +459 -0
- chrys/service/mcp/_stdio_transport.py +614 -0
- chrys/service/mcp/_tool_mixins.py +96 -0
- chrys/service/mcp/adapter.py +1031 -0
- chrys/service/mcp/cache.py +476 -0
- chrys/service/mcp/content.py +32 -0
- chrys/service/mcp/errors.py +169 -0
- chrys/service/mcp/owned.py +1330 -0
- chrys/service/mcp/result_limits.py +168 -0
- chrys/service/mcp/thinking_warning.py +95 -0
- chrys/service/mcp/validation.py +74 -0
- chrys/service/mutations/__init__.py +84 -0
- chrys/service/mutations/coordination.py +894 -0
- chrys/service/mutations/detector.py +458 -0
- chrys/service/mutations/git_calibrator.py +397 -0
- chrys/service/mutations/git_state.py +647 -0
- chrys/service/mutations/pipeline.py +779 -0
- chrys/service/mutations/scanner.py +466 -0
- chrys/service/mutations/snapshot_files.py +233 -0
- chrys/service/mutations/store.py +625 -0
- chrys/service/mutations/tool_names.py +15 -0
- chrys/service/mutations/trace.py +246 -0
- chrys/service/mutations/tracker.py +1289 -0
- chrys/service/mutations/types.py +559 -0
- chrys/service/mutations/workspace_changes.py +1583 -0
- chrys/service/profiles/__init__.py +2 -0
- chrys/service/profiles/agents/__init__.py +3 -0
- chrys/service/profiles/agents/builtins/Code.yaml +199 -0
- chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
- chrys/service/profiles/agents/builtins/General.yaml +114 -0
- chrys/service/profiles/agents/builtins/QA.yaml +128 -0
- chrys/service/profiles/agents/loader.py +808 -0
- chrys/service/profiles/agents/registry.py +727 -0
- chrys/service/profiles/agents/schema.py +371 -0
- chrys/service/profiles/agents/serializer.py +196 -0
- chrys/service/profiles/coercion.py +22 -0
- chrys/service/profiles/models/__init__.py +3 -0
- chrys/service/profiles/models/env_bridge.py +157 -0
- chrys/service/profiles/models/loader.py +282 -0
- chrys/service/profiles/models/options.py +438 -0
- chrys/service/profiles/models/registry.py +104 -0
- chrys/service/profiles/models/resolver.py +286 -0
- chrys/service/profiles/models/schema.py +91 -0
- chrys/service/profiles/models/serializer.py +76 -0
- chrys/service/session/__init__.py +3 -0
- chrys/service/session/checkpoint.py +156 -0
- chrys/service/session/history.py +1616 -0
- chrys/service/session/message_metadata.py +151 -0
- chrys/service/session/persistence.py +435 -0
- chrys/service/session/runtime_metadata.py +210 -0
- chrys/service/session/sub_agent_logs.py +1069 -0
- chrys/service/session/sub_agent_transcript.py +654 -0
- chrys/service/skills/__init__.py +3 -0
- chrys/service/skills/adapter.py +408 -0
- chrys/service/skills/constants.py +8 -0
- chrys/service/skills/loader.py +575 -0
- chrys/service/skills/model.py +130 -0
- chrys/service/skills/provider.py +841 -0
- chrys/service/skills/runner.py +390 -0
- chrys/service/state/__init__.py +3 -0
- chrys/service/state/_fork.py +710 -0
- chrys/service/state/_session_files.py +175 -0
- chrys/service/state/_session_meta.py +552 -0
- chrys/service/state/locks.py +154 -0
- chrys/service/state/serializers.py +156 -0
- chrys/service/state/session_catalog.py +361 -0
- chrys/service/state/session_listing.py +84 -0
- chrys/service/state/session_migration.py +446 -0
- chrys/service/state/session_mru.py +315 -0
- chrys/service/state/store.py +2132 -0
- chrys/service/state/workflow.py +158 -0
- chrys/service/text_blocks.py +47 -0
- chrys/service/todos/__init__.py +3 -0
- chrys/service/todos/reminder.py +31 -0
- chrys/service/todos/tracker.py +58 -0
- chrys/service/tools/__init__.py +3 -0
- chrys/service/tools/approval_targets.py +92 -0
- chrys/service/tools/builtins/__init__.py +3 -0
- chrys/service/tools/builtins/ask_user.py +326 -0
- chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
- chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
- chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
- chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
- chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
- chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
- chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
- chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
- chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
- chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
- chrys/service/tools/builtins/doc_converter/registry.py +55 -0
- chrys/service/tools/builtins/filesystem.py +1071 -0
- chrys/service/tools/builtins/search.py +1265 -0
- chrys/service/tools/builtins/shell.py +967 -0
- chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
- chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
- chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
- chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
- chrys/service/tools/builtins/shell_filter.py +24 -0
- chrys/service/tools/builtins/sleep.py +91 -0
- chrys/service/tools/builtins/todo.py +64 -0
- chrys/service/tools/builtins/web/__init__.py +6 -0
- chrys/service/tools/builtins/web/build.py +268 -0
- chrys/service/tools/builtins/web/config.py +338 -0
- chrys/service/tools/builtins/web/credentials.py +37 -0
- chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
- chrys/service/tools/builtins/web/fetch/cache.py +68 -0
- chrys/service/tools/builtins/web/fetch/convert.py +56 -0
- chrys/service/tools/builtins/web/fetch/tool.py +223 -0
- chrys/service/tools/builtins/web/hosted.py +61 -0
- chrys/service/tools/builtins/web/http.py +330 -0
- chrys/service/tools/builtins/web/progress.py +18 -0
- chrys/service/tools/builtins/web/search/__init__.py +3 -0
- chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
- chrys/service/tools/builtins/web/search/html.py +152 -0
- chrys/service/tools/builtins/web/search/json_api.py +227 -0
- chrys/service/tools/builtins/web/search/providers.py +77 -0
- chrys/service/tools/builtins/web/search/tool.py +218 -0
- chrys/service/tools/builtins/web/search/types.py +28 -0
- chrys/service/tools/builtins/web/tls_identity.py +66 -0
- chrys/service/tools/kinds.py +115 -0
- chrys/service/tools/names.py +47 -0
- chrys/service/tools/registry.py +242 -0
- chrys/service/tools/result_metadata.py +103 -0
- chrys/service/tools/session_artifacts.py +196 -0
- chrys/service/tools/spill.py +218 -0
- chrys/service/tools/workspace_paths.py +81 -0
- chrys/service/trajectory/__init__.py +11 -0
- chrys/service/trajectory/approvals.py +201 -0
- chrys/service/trajectory/compaction.py +420 -0
- chrys/service/trajectory/fork.py +75 -0
- chrys/service/trajectory/hooks.py +217 -0
- chrys/service/trajectory/items.py +41 -0
- chrys/service/trajectory/preparation.py +374 -0
- chrys/service/trajectory/retries.py +95 -0
- chrys/service/trajectory/revisions.py +129 -0
- chrys/service/trajectory/segmented.py +163 -0
- chrys/service/trajectory/session.py +762 -0
- chrys/service/trajectory/state.py +83 -0
- chrys/service/trajectory/sub_agents.py +169 -0
- chrys/service/trajectory/tombstone.py +262 -0
- chrys/service/trajectory/tools.py +323 -0
- chrys/service/trajectory/validation.py +130 -0
- chrys/service/trajectory/waits.py +161 -0
- chrys/service/trajectory/workflow.py +148 -0
- chrys/service/vision.py +128 -0
- chrys/service/workflows/__init__.py +3 -0
- chrys/service/workflows/admission.py +151 -0
- chrys/service/workflows/artifacts.py +109 -0
- chrys/service/workflows/asks.py +130 -0
- chrys/service/workflows/builtins/__init__.py +12 -0
- chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
- chrys/service/workflows/builtins/demo-workflow.py +756 -0
- chrys/service/workflows/discovery.py +554 -0
- chrys/service/workflows/environment.py +284 -0
- chrys/service/workflows/graph.py +430 -0
- chrys/service/workflows/history.py +82 -0
- chrys/service/workflows/interpreter.py +107 -0
- chrys/service/workflows/journal.py +334 -0
- chrys/service/workflows/layout.py +56 -0
- chrys/service/workflows/ledger.py +149 -0
- chrys/service/workflows/model_selection.py +18 -0
- chrys/service/workflows/orphans.py +209 -0
- chrys/service/workflows/outcomes.py +46 -0
- chrys/service/workflows/protocol.py +210 -0
- chrys/service/workflows/records.py +142 -0
- chrys/service/workflows/scheduler.py +770 -0
- chrys/service/workflows/sdk/__init__.py +51 -0
- chrys/service/workflows/sdk/_ask.py +167 -0
- chrys/service/workflows/sdk/_builder.py +781 -0
- chrys/service/workflows/sdk/_values.py +92 -0
- chrys/service/workflows/sdk_artifact.py +74 -0
- chrys/service/workflows/store.py +494 -0
- chrys/service/workflows/transcript.py +102 -0
- chrys/service/workflows/values.py +73 -0
- chrys/service/workflows/worker_host.py +1820 -0
- chrys/workflows.py +38 -0
- icode_tui-0.29.1.dist-info/METADATA +104 -0
- icode_tui-0.29.1.dist-info/RECORD +1138 -0
- icode_tui-0.29.1.dist-info/WHEEL +4 -0
- icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
- icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
- icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
|
@@ -0,0 +1,1996 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""LAST_WORDS generator — summarises dropped tool-call work for Phase 4 compaction.
|
|
4
|
+
|
|
5
|
+
When Phase 4 compaction fires, it drops every tool-call and inline
|
|
6
|
+
assistant-text message for the current turn and replaces them with a
|
|
7
|
+
``<system-reminder>[LAST_WORDS]…</system-reminder>`` block appended to the
|
|
8
|
+
user message. This module produces the text for that reminder.
|
|
9
|
+
|
|
10
|
+
The primary path is a physically scoped natural-completion side call: the
|
|
11
|
+
client-owned :class:`chrys.kernel.LastWordsCompleter` sends only the current
|
|
12
|
+
turn's validated group timeline plus one instruction message. Tool use is
|
|
13
|
+
suppressed by the triple no-tools directive wrapped around the code-owned
|
|
14
|
+
contract and base guidance plus any profile supplement.
|
|
15
|
+
|
|
16
|
+
The original fresh two-message reconstruction prompt (fixed instruction and
|
|
17
|
+
optional supplement as the system message + a text re-rendering of the dropped
|
|
18
|
+
context, sent over a separate ``route_kind="last-words"`` session) is retained
|
|
19
|
+
strictly as the fallback, used only when the completer is absent, the provider
|
|
20
|
+
rejects the side call for context-window size, or the side call's local retry
|
|
21
|
+
budget is exhausted (including repeated illegal tool-call responses).
|
|
22
|
+
|
|
23
|
+
The LAST_WORDS note is always LLM-generated — there is no programmatic
|
|
24
|
+
fallback. On LLM failure (network error, empty response, etc.)
|
|
25
|
+
``generate()`` retries the LAST_WORDS request itself on transient failures
|
|
26
|
+
so a dropped compaction connection resends the same summariser prompt
|
|
27
|
+
instead of replaying the whole agent/tool attempt. If the fallback's
|
|
28
|
+
retries are exhausted, it raises :class:`LastWordsGenerationError` (the
|
|
29
|
+
final SDK exception is chained via ``__cause__``). Compaction requires a
|
|
30
|
+
fresh non-empty note from the current round, so no current-turn work is
|
|
31
|
+
silently dropped when generation ultimately fails.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import math
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import time
|
|
43
|
+
from collections.abc import Mapping
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from datetime import UTC, datetime
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
48
|
+
from uuid import uuid4
|
|
49
|
+
|
|
50
|
+
from chrys.foundation.errors import ProviderResponseError, clean_error_message, is_retryable, may_be_context_overflow
|
|
51
|
+
from chrys.foundation.models.turns import is_continuation_message
|
|
52
|
+
from chrys.foundation.retry import TRANSIENT_RETRY_BACKOFF_SECONDS, RetryAttemptInfo
|
|
53
|
+
from chrys.foundation.text.tokenizer import MixedLanguageTokenizer
|
|
54
|
+
from chrys.foundation.trajectory.context import side_call_scope
|
|
55
|
+
from chrys.foundation.trajectory.envelope import ActorRole
|
|
56
|
+
from chrys.foundation.trajectory.event_types import RetryMode, RetryReason
|
|
57
|
+
from chrys.kernel import (
|
|
58
|
+
TOOL_CALL_CONTENT_TYPES,
|
|
59
|
+
Content,
|
|
60
|
+
LastWordsToolCallError,
|
|
61
|
+
Message,
|
|
62
|
+
TokenizerProtocol,
|
|
63
|
+
raise_if_context_window_filled,
|
|
64
|
+
report_wire_progress,
|
|
65
|
+
)
|
|
66
|
+
from chrys.service.agent_middleware.system_reminder import escape_system_reminder_tags
|
|
67
|
+
from chrys.service.llm.one_shot import get_final_response
|
|
68
|
+
from chrys.service.profiles.agents.schema import DEFAULT_LAST_WORDS_MAX_OUTPUT_TOKENS
|
|
69
|
+
from chrys.service.profiles.models.options import (
|
|
70
|
+
AUTO_INTERLEAVED_THINKING_OPTION,
|
|
71
|
+
PROMPT_CACHE_KEY_OPTION,
|
|
72
|
+
STREAM_REQUIRES_FINISH_REASON_OPTION,
|
|
73
|
+
THINKING_BLOCK_BINDING_OPTION,
|
|
74
|
+
)
|
|
75
|
+
from chrys.service.trajectory.compaction import current_compaction_operation_id
|
|
76
|
+
from chrys.service.trajectory.retries import RetryBackoffTrace
|
|
77
|
+
|
|
78
|
+
from .groups import _render_tool_result_value
|
|
79
|
+
from .scoped import DEGRADED_SCOPED_PREAMBLE, ScopedGroup, prepare_scoped_slice
|
|
80
|
+
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
from collections.abc import Awaitable, Callable
|
|
83
|
+
from typing import Any
|
|
84
|
+
|
|
85
|
+
from chrys.kernel import LastWordsCompleter
|
|
86
|
+
from chrys.service.profiles.models.schema import ModelProfile
|
|
87
|
+
|
|
88
|
+
_log = logging.getLogger(__name__)
|
|
89
|
+
|
|
90
|
+
_COMPLETER_MAX_RETRIES = 2
|
|
91
|
+
_SLICE_SAFETY_MARGIN_TOKENS = 2_048
|
|
92
|
+
_FALLBACK_TEMPLATE_MAX_CHARS = 16_000
|
|
93
|
+
_FALLBACK_TEMPLATE_COMPACT_CHARS = 2_000
|
|
94
|
+
_FALLBACK_TEMPLATE_MIN_CHARS = 512
|
|
95
|
+
_FALLBACK_PREV_NOTE_MAX_CHARS = 24_000
|
|
96
|
+
_FALLBACK_MAX_CHARS = 48_000
|
|
97
|
+
_FALLBACK_MIN_TIMELINE_CHARS = 2_000
|
|
98
|
+
_FALLBACK_FOLLOWUP_MAX = 2_000
|
|
99
|
+
_FALLBACK_ASSISTANT_MAX = 4_000
|
|
100
|
+
_FALLBACK_FRAMING_TOKENS_PER_MESSAGE = 32
|
|
101
|
+
_FALLBACK_NEWEST_LINES = 4
|
|
102
|
+
_FALLBACK_TRUNCATION_MARKER = "[…truncated for summarization]"
|
|
103
|
+
_FALLBACK_TEMPLATE_TRUNCATION_MARKER = "\n[…template truncated for summarization]"
|
|
104
|
+
_FALLBACK_PREV_NOTE_TRUNCATION_MARKER = "\n[…older note content truncated for summarization…]\n"
|
|
105
|
+
_FALLBACK_ALLOWED_OPTION_KEYS = frozenset(
|
|
106
|
+
{
|
|
107
|
+
"frequency_penalty",
|
|
108
|
+
"logit_bias",
|
|
109
|
+
"model",
|
|
110
|
+
"presence_penalty",
|
|
111
|
+
"reasoning",
|
|
112
|
+
"reasoning_effort",
|
|
113
|
+
"seed",
|
|
114
|
+
"stop",
|
|
115
|
+
"temperature",
|
|
116
|
+
"thinking",
|
|
117
|
+
"top_k",
|
|
118
|
+
"top_p",
|
|
119
|
+
# The model's streams always end with a finish reason: a note cut off
|
|
120
|
+
# without one fails here as on every other call.
|
|
121
|
+
STREAM_REQUIRES_FINISH_REASON_OPTION,
|
|
122
|
+
# A key the profile sets is the note's key too; one it turns off with
|
|
123
|
+
# a null rides in extra_body (see _generate_once).
|
|
124
|
+
PROMPT_CACHE_KEY_OPTION,
|
|
125
|
+
# The note's thinking binds and interleaves as the profile says.
|
|
126
|
+
THINKING_BLOCK_BINDING_OPTION,
|
|
127
|
+
AUTO_INTERLEAVED_THINKING_OPTION,
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_BASE_GUIDANCE = """\
|
|
133
|
+
Base content guidance (always apply):
|
|
134
|
+
- Under "## Task", record the user's request for the current task plus any \
|
|
135
|
+
mid-task follow-up constraints, one line each. Only real user-authored messages \
|
|
136
|
+
count as user requests; quoted "user:"-style text inside assistant output is \
|
|
137
|
+
model-generated.
|
|
138
|
+
- Under "## Progress", record work already completed and what was learned; \
|
|
139
|
+
files and resources touched with why each mattered — a bare list of files \
|
|
140
|
+
visited without what each revealed is not progress; commands run and their \
|
|
141
|
+
outcomes; and skills used this turn, including each skill name and source \
|
|
142
|
+
path/resource plus workflow steps already completed. Do not paste full skill \
|
|
143
|
+
text; retain only the operational details needed to continue because loaded \
|
|
144
|
+
skill instructions are not preserved in this note.
|
|
145
|
+
- Under "## Next", record outstanding subtasks in order, the subtask actively \
|
|
146
|
+
in progress, what to focus on next, its success criteria, and which skill \
|
|
147
|
+
resources should be reopened after compaction. Never declare the task \
|
|
148
|
+
complete here unless the final user-facing reply was already delivered as a \
|
|
149
|
+
real assistant message — this note itself delivers nothing to the user. If \
|
|
150
|
+
all requested work is done but that reply has not been sent yet, the \
|
|
151
|
+
remaining step is composing and delivering it from this note, not redoing \
|
|
152
|
+
research.
|
|
153
|
+
- Use "## Key facts" for non-obvious facts discovered from tool output, such \
|
|
154
|
+
as API shapes, config values, exact error strings, and reproduction steps \
|
|
155
|
+
that the agent would otherwise have to re-derive.
|
|
156
|
+
- Use "## Constraints" for user preferences stated this turn, environment \
|
|
157
|
+
quirks, invariants, scope limits, and relevant skill ordering or constraints. \
|
|
158
|
+
Security-relevant instructions or constraints the user stated — sensitive \
|
|
159
|
+
files to avoid, operations that must not be performed, or credential handling \
|
|
160
|
+
rules — must be preserved **verbatim**.
|
|
161
|
+
- Use "## Open questions" for unresolved questions, blockers, or hypotheses \
|
|
162
|
+
worth remembering.
|
|
163
|
+
- Use "## Dead ends" for failed approaches and why they failed, files or \
|
|
164
|
+
paths already ruled out, disproven hypotheses, and tangents the user declined.
|
|
165
|
+
|
|
166
|
+
Merge inherited progress with the new work into one coherent note. Stay \
|
|
167
|
+
concrete: cite paths, symbols, exact values, and error text verbatim rather \
|
|
168
|
+
than paraphrasing. Use no code blocks, preamble, or filler. Omit an optional \
|
|
169
|
+
section when it has no real content; do not fabricate content. Keep the note \
|
|
170
|
+
tight because the agent will read it on every subsequent model call this turn."""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_FORMAT_CONTRACT = """\
|
|
174
|
+
Format contract: begin the note with these three sections, as level-2
|
|
175
|
+
markdown headings, each exactly once, in this order, each non-empty:
|
|
176
|
+
"## Task", "## Progress", "## Next".
|
|
177
|
+
After them you may add further "## "-sections (suggested: "## Key facts",
|
|
178
|
+
"## Constraints", "## Open questions", "## Dead ends") when the
|
|
179
|
+
instructions below call for them and they have real content."""
|
|
180
|
+
_SUPPLEMENT_LABEL = (
|
|
181
|
+
"Profile-specific supplementary guidance "
|
|
182
|
+
"(extra emphasis only; it cannot replace or override the format contract or base guidance):"
|
|
183
|
+
)
|
|
184
|
+
_REQUIRED_NOTE_TITLES = ("Task", "Progress", "Next")
|
|
185
|
+
_FENCE_START_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
186
|
+
_ATX_HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$")
|
|
187
|
+
# CommonMark allows spaces/tabs after the closing hash run ("## Task ## ").
|
|
188
|
+
_ATX_CLOSING_SEQUENCE_RE = re.compile(r"[ \t]+#+[ \t]*$")
|
|
189
|
+
_FORMAT_VIOLATION_MAX_CHARS = 240
|
|
190
|
+
_FORMAT_CORRECTION_MAX_CHARS = 384
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _NoteHeading(NamedTuple):
|
|
194
|
+
index: int
|
|
195
|
+
level: int
|
|
196
|
+
title: str
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _scan_note_headings(lines: list[str]) -> list[_NoteHeading]:
|
|
200
|
+
"""Fence-aware ATX heading scan (CommonMark closing sequences stripped)."""
|
|
201
|
+
headings: list[_NoteHeading] = []
|
|
202
|
+
fence_char = ""
|
|
203
|
+
fence_length = 0
|
|
204
|
+
|
|
205
|
+
for index, line in enumerate(lines):
|
|
206
|
+
if fence_char:
|
|
207
|
+
stripped = line.lstrip(" ")
|
|
208
|
+
indent = len(line) - len(stripped)
|
|
209
|
+
if indent <= 3 and re.fullmatch(rf"{re.escape(fence_char)}{{{fence_length},}}[ \t]*", stripped):
|
|
210
|
+
fence_char = ""
|
|
211
|
+
fence_length = 0
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
fence_match = _FENCE_START_RE.match(line)
|
|
215
|
+
if fence_match is not None:
|
|
216
|
+
marker = fence_match.group(1)
|
|
217
|
+
info = fence_match.group(2)
|
|
218
|
+
# CommonMark forbids backticks in a backtick fence's info
|
|
219
|
+
# string. Treating such a line as a fence would let an ordinary
|
|
220
|
+
# model line hide duplicate or missing required headings.
|
|
221
|
+
if marker[0] != "`" or "`" not in info:
|
|
222
|
+
fence_char = marker[0]
|
|
223
|
+
fence_length = len(marker)
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
heading_match = _ATX_HEADING_RE.match(line)
|
|
227
|
+
if heading_match is not None:
|
|
228
|
+
title = _ATX_CLOSING_SEQUENCE_RE.sub("", heading_match.group(2) or "").rstrip(" \t")
|
|
229
|
+
headings.append(_NoteHeading(index, len(heading_match.group(1)), title))
|
|
230
|
+
|
|
231
|
+
return headings
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _validate_note_format(text: str) -> tuple[str | None, str]:
|
|
235
|
+
"""Validate the structured-note grammar and canonicalize repairable drift.
|
|
236
|
+
|
|
237
|
+
Returns ``(first violation or None, canonical note text)``. The grammar
|
|
238
|
+
is deliberately laxer than what the prompt requests: the required
|
|
239
|
+
sections ("Task", "Progress", "Next") are accepted at any ATX level and
|
|
240
|
+
in any order, and extra sections may appear between them — those are
|
|
241
|
+
mechanical drift, so acceptance repairs them (headings normalized to
|
|
242
|
+
``##``, required sections reordered, extras moved after) instead of
|
|
243
|
+
burning a corrective side call. Hard violations remain hard: a missing
|
|
244
|
+
or duplicated required section is ambiguous, an empty one defeats the
|
|
245
|
+
note's purpose, and prose or a provider wrapper before the first section
|
|
246
|
+
heading means the response is not the bare note that was requested.
|
|
247
|
+
Subsection headings (level 3+, non-required titles) belong to the
|
|
248
|
+
enclosing section's body and count as content.
|
|
249
|
+
"""
|
|
250
|
+
lines = text.splitlines()
|
|
251
|
+
headings = _scan_note_headings(lines)
|
|
252
|
+
# Section boundaries: any level-1/2 heading, plus required titles at any
|
|
253
|
+
# level (a "### Next" is still the Next section, just mis-levelled).
|
|
254
|
+
boundaries = [h for h in headings if h.level <= 2 or h.title in _REQUIRED_NOTE_TITLES]
|
|
255
|
+
|
|
256
|
+
required_found: dict[str, _NoteHeading] = {}
|
|
257
|
+
for required in _REQUIRED_NOTE_TITLES:
|
|
258
|
+
positions = [heading for heading in boundaries if heading.title == required]
|
|
259
|
+
if not positions:
|
|
260
|
+
return f'missing required heading "## {required}"', text
|
|
261
|
+
if len(positions) > 1:
|
|
262
|
+
return f'required heading "## {required}" appears more than once', text
|
|
263
|
+
required_found[required] = positions[0]
|
|
264
|
+
|
|
265
|
+
first_non_blank = next((index for index, line in enumerate(lines) if line.strip()), None)
|
|
266
|
+
if not boundaries or boundaries[0].index != first_non_blank:
|
|
267
|
+
return 'note must begin with required heading "## Task"', text
|
|
268
|
+
|
|
269
|
+
for position, boundary in enumerate(boundaries):
|
|
270
|
+
if boundary.title not in _REQUIRED_NOTE_TITLES:
|
|
271
|
+
continue
|
|
272
|
+
end = boundaries[position + 1].index if position + 1 < len(boundaries) else len(lines)
|
|
273
|
+
if not any(line.strip() for line in lines[boundary.index + 1 : end]):
|
|
274
|
+
return f'section "## {boundary.title}" has no non-blank body content', text
|
|
275
|
+
|
|
276
|
+
return None, _canonicalize_note(text, lines, boundaries)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _canonicalize_note(text: str, lines: list[str], boundaries: list[_NoteHeading]) -> str:
|
|
280
|
+
"""Rebuild a valid note into canonical shape; keep already-canonical bytes."""
|
|
281
|
+
required_order = [b for b in boundaries if b.title in _REQUIRED_NOTE_TITLES]
|
|
282
|
+
last_required_index = required_order[-1].index if required_order else 0
|
|
283
|
+
already_canonical = (
|
|
284
|
+
[b.title for b in required_order] == list(_REQUIRED_NOTE_TITLES)
|
|
285
|
+
# The exact heading line, not just the level: indent, trailing
|
|
286
|
+
# whitespace, and closing sequences ("## Task ##") all rebuild.
|
|
287
|
+
and all(lines[b.index] == f"## {b.title}" for b in required_order)
|
|
288
|
+
and all(b.index > last_required_index for b in boundaries if b.title not in _REQUIRED_NOTE_TITLES)
|
|
289
|
+
)
|
|
290
|
+
if already_canonical:
|
|
291
|
+
return text
|
|
292
|
+
|
|
293
|
+
required_sections: dict[str, str] = {}
|
|
294
|
+
extra_sections: list[str] = []
|
|
295
|
+
for position, boundary in enumerate(boundaries):
|
|
296
|
+
end = boundaries[position + 1].index if position + 1 < len(boundaries) else len(lines)
|
|
297
|
+
body = lines[boundary.index + 1 : end]
|
|
298
|
+
while body and not body[-1].strip():
|
|
299
|
+
body.pop()
|
|
300
|
+
if boundary.title in _REQUIRED_NOTE_TITLES:
|
|
301
|
+
required_sections[boundary.title] = "\n".join([f"## {boundary.title}", *body])
|
|
302
|
+
else:
|
|
303
|
+
# Extra sections keep their exact heading line; only their
|
|
304
|
+
# placement (after the required prefix) is canonicalized.
|
|
305
|
+
extra_sections.append("\n".join([lines[boundary.index], *body]))
|
|
306
|
+
ordered = [required_sections[title] for title in _REQUIRED_NOTE_TITLES]
|
|
307
|
+
return "\n\n".join([*ordered, *extra_sections])
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _note_format_violation(text: str) -> str | None:
|
|
311
|
+
"""Return the first structured-note grammar violation, if any."""
|
|
312
|
+
return _validate_note_format(text)[0]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# Minimum note length the prompts request. Sits well above the enforcement
|
|
316
|
+
# floor (``LastWordsGenerator._MIN_NOTE_CHARS``, measured in characters) so a
|
|
317
|
+
# compliant model never trips it. When the note budget falls below this
|
|
318
|
+
# (extended thinking squeezing a small output cap), ``_output_budgets`` warns:
|
|
319
|
+
# the prompt's minimum and the stated cap become contradictory.
|
|
320
|
+
_MIN_NOTE_TOKENS = 500
|
|
321
|
+
|
|
322
|
+
# The completer side call keeps the live request's tools list untouched but
|
|
323
|
+
# forces ``tool_choice`` to ``"none"``: instruction-only suppression proved
|
|
324
|
+
# insufficient (models still attempted typed tool calls), and the cache
|
|
325
|
+
# argument for inheriting the live value died with the physically scoped
|
|
326
|
+
# slice — the side call's message prefix already diverges from the live call,
|
|
327
|
+
# so the options delta forfeits nothing. These no-tools directives stay on
|
|
328
|
+
# top: stated once before and twice after the guidance, because
|
|
329
|
+
# ``tool_choice`` cannot stop tool-call MARKUP emitted as plain text (live
|
|
330
|
+
# DeepSeek did exactly that in 7/9 side calls). The client-side response
|
|
331
|
+
# guard remains the hard backstop for typed tool calls.
|
|
332
|
+
_NO_TOOLS_OPENER = (
|
|
333
|
+
"IMPORTANT: Do NOT call any tools while handling this request. Reply with plain text "
|
|
334
|
+
"only. This is a mechanical summarisation task — do not deliberate or plan in "
|
|
335
|
+
"extended thinking; start writing the note immediately and spend your output on its "
|
|
336
|
+
"content. Write at least {min_note_tokens} tokens of note content, and size the note "
|
|
337
|
+
"to finish comfortably under the {max_output_tokens}-token output cap, because "
|
|
338
|
+
"anything beyond the cap is cut off mid-sentence and lost."
|
|
339
|
+
)
|
|
340
|
+
_NO_TOOLS_REMINDER = (
|
|
341
|
+
"Reminder: do NOT call any tools, and do NOT emit tool-call markup of any kind "
|
|
342
|
+
"(no function-call syntax, no XML- or DSML-style tool tags). The progress note "
|
|
343
|
+
"itself, written as plain prose, is the only valid reply."
|
|
344
|
+
)
|
|
345
|
+
_NO_TOOLS_FINAL = (
|
|
346
|
+
"Once more: do NOT call any tools, and do not spend output on deliberation. Final "
|
|
347
|
+
"check before you answer: the reply must contain zero tool calls and zero tool-call "
|
|
348
|
+
"markup — plain text only, at least {min_note_tokens} tokens of note content, "
|
|
349
|
+
"concluding within the {max_output_tokens}-token output cap."
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# The reconstruction fallback shares the completer's length contract. The
|
|
354
|
+
# base guidance bounds verbosity ("keep the note tight") but states no minimum, so
|
|
355
|
+
# an obediently terse note would starve the note floor (``_MIN_NOTE_CHARS``)
|
|
356
|
+
# into retries; the explicit minimum keeps compliant models above it.
|
|
357
|
+
_FALLBACK_LENGTH_DIRECTIVE = (
|
|
358
|
+
"Write at least {min_note_tokens} tokens of note content — the note replaces the "
|
|
359
|
+
"agent's entire dropped tool-call history, so a fragmentary note loses real work — "
|
|
360
|
+
"and size it to finish comfortably under the {max_output_tokens}-token "
|
|
361
|
+
"output cap, because anything beyond the cap is cut off mid-sentence and lost."
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _prompt_block(tag: str, content: str) -> str:
|
|
366
|
+
"""Render an XML-ish data block, defanging the block's OWN tag literals.
|
|
367
|
+
|
|
368
|
+
``escape_system_reminder_tags`` only defangs the two reminder tags —
|
|
369
|
+
content containing this block's closing tag would terminate the block
|
|
370
|
+
early and the remainder would masquerade as prompt structure. The full
|
|
371
|
+
multiline form is kept (these blocks are the note-writer's full-fidelity
|
|
372
|
+
copy); directive-shaped content is contained by keeping the block's
|
|
373
|
+
delimiters intact, which is exactly what the defang restores.
|
|
374
|
+
"""
|
|
375
|
+
safe = content.replace(f"<{tag}>", f"<{tag}>").replace(f"</{tag}>", f"</{tag}>")
|
|
376
|
+
return f"<{tag}>\n{safe}\n</{tag}>"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _completer_instruction(
|
|
380
|
+
template: str,
|
|
381
|
+
*,
|
|
382
|
+
has_continuation_nudges: bool,
|
|
383
|
+
max_output_tokens: int,
|
|
384
|
+
format_correction: str = "",
|
|
385
|
+
) -> str:
|
|
386
|
+
"""The fixed note contract and optional supplement wrapped in no-tools directives.
|
|
387
|
+
|
|
388
|
+
The output cap is stated up front and repeated in the final check so the model
|
|
389
|
+
sizes the note to conclude within ``max_output_tokens`` instead of
|
|
390
|
+
running past it (observed live: GPT-5.4 wrote a 23.8k-char note into an
|
|
391
|
+
8k cap and got truncated with ``finish_reason:"length"``). A 500-token
|
|
392
|
+
minimum and a no-deliberation hint push back on the opposite failure,
|
|
393
|
+
observed on Responses-API reasoning models: thinking consumed the whole
|
|
394
|
+
output budget and the visible note collapsed to a mid-sentence fragment
|
|
395
|
+
(or nothing at all).
|
|
396
|
+
"""
|
|
397
|
+
body = (
|
|
398
|
+
"Everything in this conversation is the current, in-progress task.\n"
|
|
399
|
+
"Summarize ALL of it — your note replaces this conversation's tool history.\n"
|
|
400
|
+
"A [LAST_WORDS] block in the last user message is your own previous progress\n"
|
|
401
|
+
"note (inherited state, not user input): merge it with the new work."
|
|
402
|
+
)
|
|
403
|
+
if has_continuation_nudges:
|
|
404
|
+
body += (
|
|
405
|
+
'\nUser messages that just say "continue" are automatic resume nudges after an interruption — '
|
|
406
|
+
"they are not user input; ignore them as content."
|
|
407
|
+
)
|
|
408
|
+
return _assemble_instruction(
|
|
409
|
+
body,
|
|
410
|
+
template,
|
|
411
|
+
max_output_tokens=max_output_tokens,
|
|
412
|
+
format_correction=format_correction,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _fallback_instruction(
|
|
417
|
+
template: str,
|
|
418
|
+
*,
|
|
419
|
+
has_continuation_nudges: bool,
|
|
420
|
+
max_output_tokens: int,
|
|
421
|
+
format_correction: str = "",
|
|
422
|
+
) -> str:
|
|
423
|
+
"""Build the fallback instruction with the same fixed contract and wrapper."""
|
|
424
|
+
body = (
|
|
425
|
+
"Everything in this conversation is the current, in-progress task.\n"
|
|
426
|
+
"Summarize ALL of it — your note replaces this conversation's tool history.\n"
|
|
427
|
+
"The <previous_progress_note> block in the user message is your own previous progress\n"
|
|
428
|
+
"note (inherited state, not user input): merge it with the new work."
|
|
429
|
+
)
|
|
430
|
+
if has_continuation_nudges:
|
|
431
|
+
body += (
|
|
432
|
+
'\nUser messages that just say "continue" are automatic resume nudges after an interruption — '
|
|
433
|
+
"they are not user input; ignore them as content."
|
|
434
|
+
)
|
|
435
|
+
return _assemble_instruction(
|
|
436
|
+
body,
|
|
437
|
+
template,
|
|
438
|
+
max_output_tokens=max_output_tokens,
|
|
439
|
+
format_correction=format_correction,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _assemble_instruction(
|
|
444
|
+
body: str,
|
|
445
|
+
template: str,
|
|
446
|
+
*,
|
|
447
|
+
max_output_tokens: int,
|
|
448
|
+
format_correction: str,
|
|
449
|
+
) -> str:
|
|
450
|
+
"""Assemble the code-owned instruction in its design-of-record order."""
|
|
451
|
+
blocks = [
|
|
452
|
+
_NO_TOOLS_OPENER.format(min_note_tokens=_MIN_NOTE_TOKENS, max_output_tokens=max_output_tokens),
|
|
453
|
+
body,
|
|
454
|
+
_FORMAT_CONTRACT,
|
|
455
|
+
_BASE_GUIDANCE,
|
|
456
|
+
]
|
|
457
|
+
if supplement := _supplement_block(template):
|
|
458
|
+
blocks.append(supplement)
|
|
459
|
+
if format_correction:
|
|
460
|
+
blocks.append(format_correction)
|
|
461
|
+
blocks.extend(
|
|
462
|
+
(
|
|
463
|
+
_NO_TOOLS_REMINDER,
|
|
464
|
+
_NO_TOOLS_FINAL.format(min_note_tokens=_MIN_NOTE_TOKENS, max_output_tokens=max_output_tokens),
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
return "\n\n".join(blocks)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _format_correction(violation: str) -> str:
|
|
471
|
+
safe_violation = " ".join(violation.split())[:_FORMAT_VIOLATION_MAX_CHARS]
|
|
472
|
+
prefix = "Your previous note was rejected: "
|
|
473
|
+
suffix = ". Re-emit the full note with the required headings."
|
|
474
|
+
available = _FORMAT_CORRECTION_MAX_CHARS - len(prefix) - len(suffix)
|
|
475
|
+
return f"{prefix}{safe_violation[:available]}{suffix}"
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _bounded_format_violation(violation: str) -> str:
|
|
479
|
+
"""Return a single-line violation safe for logs, events, and retry prompts."""
|
|
480
|
+
return " ".join(violation.split())[:_FORMAT_VIOLATION_MAX_CHARS]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _normalize_note_response(text: str | None) -> str:
|
|
484
|
+
"""Drop provider-added outer newlines without changing Markdown indentation."""
|
|
485
|
+
if text is None or not text.strip():
|
|
486
|
+
return ""
|
|
487
|
+
return text.strip("\r\n")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _truncate_template(template: str, max_chars: int = _FALLBACK_TEMPLATE_MAX_CHARS) -> str:
|
|
491
|
+
clean = template.rstrip()
|
|
492
|
+
if len(clean) <= max_chars:
|
|
493
|
+
return clean
|
|
494
|
+
keep = max(0, max_chars - len(_FALLBACK_TEMPLATE_TRUNCATION_MARKER))
|
|
495
|
+
return clean[:keep].rstrip() + _FALLBACK_TEMPLATE_TRUNCATION_MARKER
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _supplement_block(template: str, max_chars: int = _FALLBACK_TEMPLATE_MAX_CHARS) -> str:
|
|
499
|
+
"""Render the bounded profile supplement, or nothing for an empty template."""
|
|
500
|
+
if not template.strip():
|
|
501
|
+
return ""
|
|
502
|
+
supplement = _truncate_template(template, max_chars)
|
|
503
|
+
return f"{_SUPPLEMENT_LABEL}\n{supplement}"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class LastWordsGenerationError(RuntimeError):
|
|
507
|
+
"""Raised when the LAST_WORDS LLM call fails for any reason.
|
|
508
|
+
|
|
509
|
+
Wraps both transport-level failures (SDK exceptions chained via
|
|
510
|
+
``__cause__``) and empty-response failures behind a single, uniform
|
|
511
|
+
exception type. ``LastWordsGenerator`` retries the compaction LLM
|
|
512
|
+
request internally before raising this, so outer agent retry loops
|
|
513
|
+
should treat it as a terminal compaction failure, not as a reason to
|
|
514
|
+
replay completed tool work.
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class LastWordsSpendBudgetExceeded(LastWordsGenerationError):
|
|
519
|
+
"""Raised before a provider attempt would exhaust the turn spend budget."""
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# Card-facing text for a spend-budget refusal, shared with the strategy's
|
|
523
|
+
# entry-time trip so both surfaces render the identical reason.
|
|
524
|
+
SPEND_BUDGET_FAILURE_REASON = "progress-note token budget exceeded for current turn"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@dataclass(frozen=True, slots=True)
|
|
528
|
+
class _GeneratedNote:
|
|
529
|
+
text: str
|
|
530
|
+
format_violation: str = ""
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@dataclass(slots=True)
|
|
534
|
+
class _FormatRetryState:
|
|
535
|
+
violations: list[str]
|
|
536
|
+
correction_violation: str = ""
|
|
537
|
+
pending_note: _GeneratedNote | None = None
|
|
538
|
+
pending_instruction: str = ""
|
|
539
|
+
pending_request_note: str = ""
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@dataclass(frozen=True, slots=True)
|
|
543
|
+
class CompactionStatus:
|
|
544
|
+
"""UX progress signal for one LAST_WORDS generation pass.
|
|
545
|
+
|
|
546
|
+
Emitted by :meth:`LastWordsGenerator.generate` through the
|
|
547
|
+
``publish_status`` callback so frontends can show a live
|
|
548
|
+
"Compacting conversation…" indicator over the whole generation
|
|
549
|
+
window (LLM call plus local retries and backoff). ``stage`` is
|
|
550
|
+
``"started"``, ``"finished"``, or ``"committed"``; the remaining fields
|
|
551
|
+
are only meaningful on the finished signal. A ``"committed"`` signal
|
|
552
|
+
(see :meth:`LastWordsGenerator.publish_committed`) follows a
|
|
553
|
+
finished-ok signal once the compaction strategy has durably applied
|
|
554
|
+
the round — spill written, note set, messages excluded; a finished-ok
|
|
555
|
+
round without it was abandoned. ``outcome`` is ``"ok"``,
|
|
556
|
+
``"failed"``, or ``"canceled"``; ``last_words`` carries the note
|
|
557
|
+
text only when ``outcome == "ok"``. ``format_violation`` records the
|
|
558
|
+
structured-note violation when the bounded corrective retry process
|
|
559
|
+
deliberately accepted a fresh malformed note. ``failure_reason`` is a
|
|
560
|
+
short human-readable cause set on ``"failed"`` outcomes whose cause is
|
|
561
|
+
a known safety limit (spend budget, round limit); frontends show it in
|
|
562
|
+
place of the duration on the failure card.
|
|
563
|
+
"""
|
|
564
|
+
|
|
565
|
+
compaction_id: str
|
|
566
|
+
stage: str
|
|
567
|
+
outcome: str = ""
|
|
568
|
+
duration_ms: int = 0
|
|
569
|
+
last_words: str = ""
|
|
570
|
+
format_violation: str = ""
|
|
571
|
+
failure_reason: str = ""
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _log_detached_publish_failure(task: asyncio.Future) -> None:
|
|
575
|
+
"""Consume a detached committed-publish task's outcome.
|
|
576
|
+
|
|
577
|
+
The publish body swallows ordinary exceptions itself, so this normally
|
|
578
|
+
only retrieves ``None`` — but retrieving is mandatory to keep asyncio
|
|
579
|
+
from logging "exception was never retrieved" if the detached task dies.
|
|
580
|
+
"""
|
|
581
|
+
if task.cancelled():
|
|
582
|
+
return
|
|
583
|
+
exc = task.exception()
|
|
584
|
+
if exc is not None:
|
|
585
|
+
_log.debug("LAST_WORDS committed publish failed after detach", exc_info=exc)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class LastWordsGenerator:
|
|
589
|
+
"""Generates the LAST_WORDS progress note for Phase 4 compaction.
|
|
590
|
+
|
|
591
|
+
Prefers the client-owned scoped side call when the strategy passes a
|
|
592
|
+
completer (see :meth:`generate`); otherwise falls back to a
|
|
593
|
+
single-turn reconstruction call using the owning agent's
|
|
594
|
+
``ModelProfile`` (model, HTTP options, chat_options) over a separate
|
|
595
|
+
``route_kind="last-words"`` session. Always LLM-generated — transient
|
|
596
|
+
failures retry the same compaction prompt locally with the standard
|
|
597
|
+
retry budget and backoff. Callers (the compaction strategy) must not
|
|
598
|
+
drop current-turn work unless generation succeeds.
|
|
599
|
+
"""
|
|
600
|
+
|
|
601
|
+
_MAX_RETRIES: int = 5
|
|
602
|
+
_MAX_CORRECTIVE_RETRIES: int = 5
|
|
603
|
+
_BACKOFF_SCHEDULE: tuple[float, ...] = TRANSIENT_RETRY_BACKOFF_SECONDS
|
|
604
|
+
# A LAST_WORDS note replaces an entire amputated turn, and Phase 4 only
|
|
605
|
+
# fires on a near-full context — there is always substantial work to
|
|
606
|
+
# record, so a fragmentary note is worse than a retry. Observed live
|
|
607
|
+
# (GPT-5.4 Responses): reasoning consumed the whole output budget and the
|
|
608
|
+
# visible note collapsed to an 85-char mid-sentence fragment that passed
|
|
609
|
+
# the old non-empty check. The floor sits far below any compliant note
|
|
610
|
+
# (the instruction asks for ``_MIN_NOTE_TOKENS``) but far above such fragments.
|
|
611
|
+
_MIN_NOTE_CHARS: int = 300
|
|
612
|
+
|
|
613
|
+
def __init__(
|
|
614
|
+
self,
|
|
615
|
+
profile: ModelProfile,
|
|
616
|
+
template: str = "",
|
|
617
|
+
max_output_tokens: int = DEFAULT_LAST_WORDS_MAX_OUTPUT_TOKENS,
|
|
618
|
+
log_dir: Path | None = None,
|
|
619
|
+
session_id: str | None = None,
|
|
620
|
+
parent_session_id: str | None = None,
|
|
621
|
+
session_dir: Path | None = None,
|
|
622
|
+
max_transient_retries: int | None = None,
|
|
623
|
+
publish_retry: Callable[[RetryAttemptInfo], Awaitable[None]] | None = None,
|
|
624
|
+
publish_status: Callable[[CompactionStatus], Awaitable[None]] | None = None,
|
|
625
|
+
report_usage: Callable[[Mapping[str, Any]], None] | None = None,
|
|
626
|
+
) -> None:
|
|
627
|
+
self._profile = profile
|
|
628
|
+
self._template = template
|
|
629
|
+
self._max_output_tokens = max_output_tokens
|
|
630
|
+
self._log_dir = log_dir
|
|
631
|
+
self._session_id = session_id
|
|
632
|
+
self._parent_session_id = parent_session_id
|
|
633
|
+
self._session_dir = session_dir
|
|
634
|
+
self._max_transient_retries = max(
|
|
635
|
+
0,
|
|
636
|
+
max_transient_retries if max_transient_retries is not None else type(self)._MAX_RETRIES,
|
|
637
|
+
)
|
|
638
|
+
self._publish_retry = publish_retry
|
|
639
|
+
self._publish_status = publish_status
|
|
640
|
+
self._report_usage = report_usage
|
|
641
|
+
self._last_compaction_id = ""
|
|
642
|
+
# Strong refs to in-flight detached committed publishes — asyncio
|
|
643
|
+
# only weakly references running tasks, so without an anchor a
|
|
644
|
+
# publish that outlives its cancelled shield could be
|
|
645
|
+
# garbage-collected before delivering.
|
|
646
|
+
self._detached_publishes: set[asyncio.Future] = set()
|
|
647
|
+
self._client = None
|
|
648
|
+
self._client_lock = asyncio.Lock()
|
|
649
|
+
self._closed = False
|
|
650
|
+
self._chat_options: dict | None = None
|
|
651
|
+
|
|
652
|
+
def _report_side_call_usage(self, usage_details: Mapping[str, Any]) -> None:
|
|
653
|
+
"""Forward one side-call response's provider-reported usage.
|
|
654
|
+
|
|
655
|
+
Every attempt spends real tokens — including responses that later
|
|
656
|
+
fail validation or acceptance — so this fires per response observed.
|
|
657
|
+
A telemetry sink must never break note generation: failures are
|
|
658
|
+
swallowed to a debug log.
|
|
659
|
+
"""
|
|
660
|
+
if self._report_usage is None or not usage_details:
|
|
661
|
+
return
|
|
662
|
+
try:
|
|
663
|
+
self._report_usage(usage_details)
|
|
664
|
+
except Exception:
|
|
665
|
+
_log.debug("side-call usage report failed", exc_info=True)
|
|
666
|
+
|
|
667
|
+
async def _get_client(self):
|
|
668
|
+
# Fallback-only: the lazily created separate client (and its derived
|
|
669
|
+
# route_kind="last-words" session) never serves the completer path.
|
|
670
|
+
async with self._client_lock:
|
|
671
|
+
if self._closed:
|
|
672
|
+
raise LastWordsGenerationError("last-words generator is closed")
|
|
673
|
+
if self._client is None:
|
|
674
|
+
from chrys.service.llm.clients import create_client
|
|
675
|
+
|
|
676
|
+
self._client = await create_client(
|
|
677
|
+
self._profile,
|
|
678
|
+
session_id=self._session_id,
|
|
679
|
+
parent_session_id=self._parent_session_id,
|
|
680
|
+
session_dir=self._session_dir,
|
|
681
|
+
)
|
|
682
|
+
return self._client
|
|
683
|
+
|
|
684
|
+
async def aclose(self) -> None:
|
|
685
|
+
"""Close the fallback client, if one was created; later generations are refused."""
|
|
686
|
+
self._closed = True
|
|
687
|
+
async with self._client_lock:
|
|
688
|
+
client, self._client = self._client, None
|
|
689
|
+
if client is not None:
|
|
690
|
+
await client.aclose()
|
|
691
|
+
|
|
692
|
+
def _profile_chat_options(self) -> dict:
|
|
693
|
+
"""Parsed profile chat options, cached (fallback options + budget math)."""
|
|
694
|
+
if self._chat_options is None:
|
|
695
|
+
from chrys.service.profiles.models.options import effective_chat_options
|
|
696
|
+
|
|
697
|
+
self._chat_options = effective_chat_options(self._profile) or {}
|
|
698
|
+
return self._chat_options
|
|
699
|
+
|
|
700
|
+
def _thinking_budget_tokens(self) -> int:
|
|
701
|
+
"""Extended-thinking budget riding in the profile options (0 = none).
|
|
702
|
+
|
|
703
|
+
Anthropic's ``thinking.budget_tokens`` spends from the same
|
|
704
|
+
``max_tokens`` pool as the visible note, and the API rejects any
|
|
705
|
+
request whose ``max_tokens`` does not exceed it.
|
|
706
|
+
"""
|
|
707
|
+
from chrys.service.profiles.models.options import output_cap_option_value
|
|
708
|
+
|
|
709
|
+
thinking = self._profile_chat_options().get("thinking")
|
|
710
|
+
if isinstance(thinking, Mapping) and thinking.get("type") == "enabled":
|
|
711
|
+
budget = output_cap_option_value(thinking, "budget_tokens")
|
|
712
|
+
if budget is not None:
|
|
713
|
+
return budget
|
|
714
|
+
return 0
|
|
715
|
+
|
|
716
|
+
def _model_output_cap(self) -> int:
|
|
717
|
+
"""Hard ``max_tokens`` ceiling for note calls (0 = unknown).
|
|
718
|
+
|
|
719
|
+
Prefers the profile's explicit ``max_output_tokens``. When that is
|
|
720
|
+
unset, a user-set output cap in the profile chat options (any
|
|
721
|
+
provider spelling) serves as the ceiling instead: every live call
|
|
722
|
+
sends it, so it is known provider-valid, while anything above it is
|
|
723
|
+
unproven.
|
|
724
|
+
"""
|
|
725
|
+
cap = self._profile.max_output_tokens
|
|
726
|
+
if cap > 0:
|
|
727
|
+
return cap
|
|
728
|
+
from chrys.service.profiles.models.options import OUTPUT_CAP_OPTION_ALIASES, output_cap_option_value
|
|
729
|
+
|
|
730
|
+
options = self._profile_chat_options()
|
|
731
|
+
for alias in OUTPUT_CAP_OPTION_ALIASES:
|
|
732
|
+
value = output_cap_option_value(options, alias)
|
|
733
|
+
if value is not None:
|
|
734
|
+
return value
|
|
735
|
+
return 0
|
|
736
|
+
|
|
737
|
+
def _output_budgets(self) -> tuple[int, int]:
|
|
738
|
+
"""``(note_budget, wire_max_tokens)`` for a note-generation call.
|
|
739
|
+
|
|
740
|
+
``note_budget`` is the visible-note share stated in the prompt;
|
|
741
|
+
``wire_max_tokens`` is the ``max_tokens`` actually sent. Both start
|
|
742
|
+
from the configured ``max_output_tokens`` clamped to the model's hard
|
|
743
|
+
output cap (:meth:`_model_output_cap` — DeepSeek rejects anything
|
|
744
|
+
above 8192 outright). When the profile enables extended thinking,
|
|
745
|
+
the wire value is raised by ``budget_tokens`` (which spends from the
|
|
746
|
+
same pool) so thinking cannot starve the note, then re-clamped to
|
|
747
|
+
the cap.
|
|
748
|
+
"""
|
|
749
|
+
cap = self._model_output_cap()
|
|
750
|
+
note = self._max_output_tokens
|
|
751
|
+
if cap > 0:
|
|
752
|
+
note = min(note, cap)
|
|
753
|
+
thinking = self._thinking_budget_tokens()
|
|
754
|
+
wire = note
|
|
755
|
+
if thinking > 0:
|
|
756
|
+
wire = note + thinking
|
|
757
|
+
if cap > 0 and wire > cap:
|
|
758
|
+
wire = cap
|
|
759
|
+
note = max(wire - thinking, 1)
|
|
760
|
+
if thinking > 0 and wire <= thinking:
|
|
761
|
+
_log.warning(
|
|
762
|
+
"extended-thinking budget_tokens (%d) meets or exceeds the model output cap (%d); "
|
|
763
|
+
"LAST_WORDS note calls will likely be rejected by the provider",
|
|
764
|
+
thinking,
|
|
765
|
+
cap,
|
|
766
|
+
)
|
|
767
|
+
elif note < _MIN_NOTE_TOKENS:
|
|
768
|
+
_log.warning(
|
|
769
|
+
"LAST_WORDS note budget (%d tokens) is below the %d-token minimum the prompt asks "
|
|
770
|
+
"for (model output cap %d, extended-thinking budget %d); notes will likely be "
|
|
771
|
+
"truncated and rejected by the note-length floor",
|
|
772
|
+
note,
|
|
773
|
+
_MIN_NOTE_TOKENS,
|
|
774
|
+
cap,
|
|
775
|
+
thinking,
|
|
776
|
+
)
|
|
777
|
+
return note, wire
|
|
778
|
+
|
|
779
|
+
async def generate(
|
|
780
|
+
self,
|
|
781
|
+
scoped_groups: list[ScopedGroup],
|
|
782
|
+
previous_last_words: str | None,
|
|
783
|
+
*,
|
|
784
|
+
degraded_opener: bool,
|
|
785
|
+
has_continuation_nudges: bool,
|
|
786
|
+
completer: LastWordsCompleter | None = None,
|
|
787
|
+
tokenizer: TokenizerProtocol | None = None,
|
|
788
|
+
system_overhead_tokens: int = 0,
|
|
789
|
+
tool_definition_tokens: int = 0,
|
|
790
|
+
request_overhead_tokens: int = 0,
|
|
791
|
+
calibration_ratio: float = 1.0,
|
|
792
|
+
spend_side_call_tokens: Callable[[int], bool] | None = None,
|
|
793
|
+
) -> str:
|
|
794
|
+
"""Produce an updated LAST_WORDS note, publishing UX status signals.
|
|
795
|
+
|
|
796
|
+
``spend_side_call_tokens`` is the narrow Phase-4 admission hook: it is
|
|
797
|
+
called synchronously with the estimated input size immediately before
|
|
798
|
+
every provider attempt. Returning false raises
|
|
799
|
+
:class:`LastWordsSpendBudgetExceeded` before that request starts unless
|
|
800
|
+
a fresh malformed note is already available for safe acceptance.
|
|
801
|
+
|
|
802
|
+
Wraps :meth:`_generate` (the actual side-call/fallback machinery)
|
|
803
|
+
with a started/finished :class:`CompactionStatus` pair through
|
|
804
|
+
``publish_status`` so frontends can surface the otherwise-silent
|
|
805
|
+
generation window. The finished signal always fires — on success
|
|
806
|
+
(``outcome="ok"`` with the note text), terminal failure
|
|
807
|
+
(``"failed"``), and cancellation (``"canceled"``) — so a live
|
|
808
|
+
indicator never dangles. Status publication is UX-only: failures
|
|
809
|
+
to publish are swallowed and never affect generation — except
|
|
810
|
+
cancellation, which is always honored: delivered during the
|
|
811
|
+
started publish it aborts before the LLM call; during the
|
|
812
|
+
finished publish it propagates instead of returning the note.
|
|
813
|
+
"""
|
|
814
|
+
compaction_id = uuid4().hex
|
|
815
|
+
# Remembered so the strategy's post-commit publish_committed() call
|
|
816
|
+
# can correlate with this round's started/finished pair.
|
|
817
|
+
self._last_compaction_id = compaction_id
|
|
818
|
+
started_at = time.monotonic()
|
|
819
|
+
outcome = "failed"
|
|
820
|
+
note = ""
|
|
821
|
+
format_violation = ""
|
|
822
|
+
failure_reason = ""
|
|
823
|
+
try:
|
|
824
|
+
# Published inside the try: an interrupt that lands while this
|
|
825
|
+
# await is suspended in a frontend handler must flow through the
|
|
826
|
+
# canceled/finished machinery below — swallowing it would let
|
|
827
|
+
# the expensive LLM call start after the user already cancelled.
|
|
828
|
+
await self._publish_status_cancellable(CompactionStatus(compaction_id=compaction_id, stage="started"))
|
|
829
|
+
generated = await self._generate(
|
|
830
|
+
scoped_groups,
|
|
831
|
+
previous_last_words,
|
|
832
|
+
degraded_opener=degraded_opener,
|
|
833
|
+
has_continuation_nudges=has_continuation_nudges,
|
|
834
|
+
completer=completer,
|
|
835
|
+
tokenizer=tokenizer or MixedLanguageTokenizer(),
|
|
836
|
+
system_overhead_tokens=system_overhead_tokens,
|
|
837
|
+
tool_definition_tokens=tool_definition_tokens,
|
|
838
|
+
request_overhead_tokens=request_overhead_tokens,
|
|
839
|
+
calibration_ratio=calibration_ratio,
|
|
840
|
+
spend_side_call_tokens=spend_side_call_tokens,
|
|
841
|
+
)
|
|
842
|
+
note = generated.text
|
|
843
|
+
format_violation = generated.format_violation
|
|
844
|
+
outcome = "ok" if note else "failed"
|
|
845
|
+
return note
|
|
846
|
+
except LastWordsSpendBudgetExceeded:
|
|
847
|
+
failure_reason = SPEND_BUDGET_FAILURE_REASON
|
|
848
|
+
raise
|
|
849
|
+
except asyncio.CancelledError:
|
|
850
|
+
outcome = "canceled"
|
|
851
|
+
raise
|
|
852
|
+
finally:
|
|
853
|
+
finished = CompactionStatus(
|
|
854
|
+
compaction_id=compaction_id,
|
|
855
|
+
stage="finished",
|
|
856
|
+
outcome=outcome,
|
|
857
|
+
duration_ms=int((time.monotonic() - started_at) * 1000),
|
|
858
|
+
last_words=note if outcome == "ok" else "",
|
|
859
|
+
format_violation=format_violation if outcome == "ok" else "",
|
|
860
|
+
failure_reason=failure_reason if outcome == "failed" else "",
|
|
861
|
+
)
|
|
862
|
+
try:
|
|
863
|
+
await self._publish_status_cancellable(finished)
|
|
864
|
+
except asyncio.CancelledError:
|
|
865
|
+
# A cancellation delivered while the finished signal awaits
|
|
866
|
+
# frontend handlers must be honored — returning the note
|
|
867
|
+
# would swallow the user's interrupt. But when this
|
|
868
|
+
# ``finally`` is already unwinding a cancellation, the
|
|
869
|
+
# publish-await raises a *fresh* CancelledError; re-raising
|
|
870
|
+
# it would merely replace the original traceback, so let
|
|
871
|
+
# the propagating one win.
|
|
872
|
+
if outcome != "canceled":
|
|
873
|
+
raise
|
|
874
|
+
_log.debug("LAST_WORDS finished publish cancelled during teardown", exc_info=True)
|
|
875
|
+
|
|
876
|
+
async def _publish_status_cancellable(self, status: CompactionStatus) -> None:
|
|
877
|
+
"""Publish a status signal, letting cancellation propagate.
|
|
878
|
+
|
|
879
|
+
Ordinary publish failures are swallowed (UX-only signals must never
|
|
880
|
+
break generation), but ``CancelledError`` — and any other
|
|
881
|
+
``BaseException`` — propagates: an interrupt delivered during the
|
|
882
|
+
started publish must stop the compaction before the LLM call
|
|
883
|
+
begins, and one during the finished publish must not be traded for
|
|
884
|
+
the generated note (the caller decides when a propagating
|
|
885
|
+
cancellation already covers it).
|
|
886
|
+
"""
|
|
887
|
+
if self._publish_status is None:
|
|
888
|
+
return
|
|
889
|
+
try:
|
|
890
|
+
await self._publish_status(status)
|
|
891
|
+
except Exception:
|
|
892
|
+
_log.debug("LAST_WORDS status publish failed (stage=%s)", status.stage, exc_info=True)
|
|
893
|
+
|
|
894
|
+
async def publish_committed(self) -> None:
|
|
895
|
+
"""Publish a ``stage="committed"`` status for the last generated note.
|
|
896
|
+
|
|
897
|
+
Called by the compaction strategy only after the round is durably
|
|
898
|
+
applied — spill written (or checkpointed through recovery
|
|
899
|
+
persistence), note set, manifest appended, and messages excluded.
|
|
900
|
+
The finished-ok signal from :meth:`generate` fires *before* that
|
|
901
|
+
commit, so observers that must count real compactions (not merely
|
|
902
|
+
successful note generations) wait for this signal instead.
|
|
903
|
+
|
|
904
|
+
Delivery is shielded from cancellation: by the time this fires the
|
|
905
|
+
round is already durable, so an interrupt arriving mid-publish must
|
|
906
|
+
not erase the only signal that distinguishes a committed round from
|
|
907
|
+
an abandoned one. The detached publish finishes in the background
|
|
908
|
+
while the cancellation propagates to the caller.
|
|
909
|
+
"""
|
|
910
|
+
if not self._last_compaction_id:
|
|
911
|
+
return
|
|
912
|
+
status = CompactionStatus(compaction_id=self._last_compaction_id, stage="committed")
|
|
913
|
+
# Consumed on publish: one generate, one committed signal. A stray
|
|
914
|
+
# second call must not re-emit "committed" for an old round.
|
|
915
|
+
self._last_compaction_id = ""
|
|
916
|
+
publish = asyncio.ensure_future(self._publish_status_cancellable(status))
|
|
917
|
+
self._detached_publishes.add(publish)
|
|
918
|
+
publish.add_done_callback(self._discard_detached_publish)
|
|
919
|
+
await asyncio.shield(publish)
|
|
920
|
+
|
|
921
|
+
def _discard_detached_publish(self, task: asyncio.Future) -> None:
|
|
922
|
+
self._detached_publishes.discard(task)
|
|
923
|
+
_log_detached_publish_failure(task)
|
|
924
|
+
|
|
925
|
+
async def publish_breaker_trip(self, failure_reason: str) -> None:
|
|
926
|
+
"""Publish an immediate started+failed status pair for an entry-time trip.
|
|
927
|
+
|
|
928
|
+
When the persisted Phase-4 breaker refuses entry, the strategy skips
|
|
929
|
+
generation entirely, so :meth:`generate`'s status pair never fires.
|
|
930
|
+
This publishes one terminal failure card carrying the trip reason —
|
|
931
|
+
without it the round would vanish silently while the model window
|
|
932
|
+
keeps overflowing.
|
|
933
|
+
"""
|
|
934
|
+
compaction_id = uuid4().hex
|
|
935
|
+
await self._publish_status_cancellable(CompactionStatus(compaction_id=compaction_id, stage="started"))
|
|
936
|
+
await self._publish_status_cancellable(
|
|
937
|
+
CompactionStatus(
|
|
938
|
+
compaction_id=compaction_id,
|
|
939
|
+
stage="finished",
|
|
940
|
+
outcome="failed",
|
|
941
|
+
failure_reason=failure_reason,
|
|
942
|
+
)
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
async def _generate(
|
|
946
|
+
self,
|
|
947
|
+
scoped_groups: list[ScopedGroup],
|
|
948
|
+
previous_last_words: str | None,
|
|
949
|
+
*,
|
|
950
|
+
degraded_opener: bool,
|
|
951
|
+
has_continuation_nudges: bool,
|
|
952
|
+
completer: LastWordsCompleter | None = None,
|
|
953
|
+
tokenizer: TokenizerProtocol,
|
|
954
|
+
system_overhead_tokens: int,
|
|
955
|
+
tool_definition_tokens: int,
|
|
956
|
+
request_overhead_tokens: int,
|
|
957
|
+
calibration_ratio: float,
|
|
958
|
+
spend_side_call_tokens: Callable[[int], bool] | None,
|
|
959
|
+
) -> _GeneratedNote:
|
|
960
|
+
"""Generate from one scoped timeline, with the bounded reconstruction as terminal path."""
|
|
961
|
+
format_state = _FormatRetryState([])
|
|
962
|
+
if completer is not None:
|
|
963
|
+
side_call_note = await self._generate_via_completer(
|
|
964
|
+
completer,
|
|
965
|
+
scoped_groups,
|
|
966
|
+
tokenizer=tokenizer,
|
|
967
|
+
has_continuation_nudges=has_continuation_nudges,
|
|
968
|
+
system_overhead_tokens=system_overhead_tokens,
|
|
969
|
+
tool_definition_tokens=tool_definition_tokens,
|
|
970
|
+
request_overhead_tokens=request_overhead_tokens,
|
|
971
|
+
calibration_ratio=calibration_ratio,
|
|
972
|
+
spend_side_call_tokens=spend_side_call_tokens,
|
|
973
|
+
format_state=format_state,
|
|
974
|
+
)
|
|
975
|
+
if side_call_note is not None:
|
|
976
|
+
return side_call_note
|
|
977
|
+
_log.warning("LAST_WORDS side call demoted, generating via reconstruction fallback")
|
|
978
|
+
return await self._generate_via_fallback(
|
|
979
|
+
scoped_groups,
|
|
980
|
+
previous_last_words,
|
|
981
|
+
degraded_opener=degraded_opener,
|
|
982
|
+
has_continuation_nudges=has_continuation_nudges,
|
|
983
|
+
spend_side_call_tokens=spend_side_call_tokens,
|
|
984
|
+
format_state=format_state,
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
async def _generate_via_fallback(
|
|
988
|
+
self,
|
|
989
|
+
scoped_groups: list[ScopedGroup],
|
|
990
|
+
previous_last_words: str | None,
|
|
991
|
+
*,
|
|
992
|
+
degraded_opener: bool,
|
|
993
|
+
has_continuation_nudges: bool,
|
|
994
|
+
spend_side_call_tokens: Callable[[int], bool] | None,
|
|
995
|
+
format_state: _FormatRetryState,
|
|
996
|
+
) -> _GeneratedNote:
|
|
997
|
+
note_budget, wire_max_tokens = self._output_budgets()
|
|
998
|
+
candidate_index = 0
|
|
999
|
+
transient_attempt = 0
|
|
1000
|
+
corrective_attempt = 0
|
|
1001
|
+
last_context_error: Exception | None = None
|
|
1002
|
+
max_transient_retries = self._max_transient_retries
|
|
1003
|
+
max_corrective_retries = max(0, type(self)._MAX_CORRECTIVE_RETRIES)
|
|
1004
|
+
while True:
|
|
1005
|
+
format_correction = (
|
|
1006
|
+
_format_correction(format_state.correction_violation) if format_state.correction_violation else ""
|
|
1007
|
+
)
|
|
1008
|
+
candidates = _fallback_candidates(
|
|
1009
|
+
scoped_groups,
|
|
1010
|
+
previous_last_words,
|
|
1011
|
+
template=self._template,
|
|
1012
|
+
degraded_opener=degraded_opener,
|
|
1013
|
+
has_continuation_nudges=has_continuation_nudges,
|
|
1014
|
+
note_budget=note_budget,
|
|
1015
|
+
format_correction=format_correction,
|
|
1016
|
+
)
|
|
1017
|
+
if candidate_index >= len(candidates):
|
|
1018
|
+
if accepted := self._accept_pending_format_note(
|
|
1019
|
+
format_state,
|
|
1020
|
+
reason="corrective request could not fit the model context window",
|
|
1021
|
+
):
|
|
1022
|
+
return accepted
|
|
1023
|
+
message = "Fallback request cannot fit the model context window without dropping current-turn work"
|
|
1024
|
+
if last_context_error is not None:
|
|
1025
|
+
raise LastWordsGenerationError(message) from last_context_error
|
|
1026
|
+
raise LastWordsGenerationError(message)
|
|
1027
|
+
candidate = candidates[candidate_index]
|
|
1028
|
+
estimated_input_tokens = _fallback_admission_tokens(candidate.messages, output_reserve=0)
|
|
1029
|
+
send_max_tokens = wire_max_tokens
|
|
1030
|
+
admitted_tokens = _fallback_admission_tokens(candidate.messages, output_reserve=wire_max_tokens)
|
|
1031
|
+
if admitted_tokens > self._profile.max_context_tokens:
|
|
1032
|
+
if candidate_index < len(candidates) - 1 or format_state.pending_note is not None:
|
|
1033
|
+
# With a pending accept-with-flag note, exhaustion falls back
|
|
1034
|
+
# to accepting it, so skipping the corrective call is safe.
|
|
1035
|
+
_log.warning(
|
|
1036
|
+
"LAST_WORDS fallback candidate '%s' rejected by admission preflight (%d > %d tokens)",
|
|
1037
|
+
candidate.stage,
|
|
1038
|
+
admitted_tokens,
|
|
1039
|
+
self._profile.max_context_tokens,
|
|
1040
|
+
)
|
|
1041
|
+
candidate_index += 1
|
|
1042
|
+
continue
|
|
1043
|
+
# The byte-conservative estimate overcharges real tokenization by
|
|
1044
|
+
# roughly 4x on prose, so rejecting the last-resort candidate
|
|
1045
|
+
# locally would dead-end Phase 4 on small-context profiles whose
|
|
1046
|
+
# requests actually fit. Send it and let the provider tokenizer
|
|
1047
|
+
# rule; a genuine context rejection still ends the shrink ladder.
|
|
1048
|
+
# The output reserve is exact rather than an overestimate, so
|
|
1049
|
+
# clamp it too: providers enforcing input + max_tokens <= context
|
|
1050
|
+
# would otherwise deterministically reject profiles whose output
|
|
1051
|
+
# cap rivals their context window. Keep at least the note floor
|
|
1052
|
+
# (plus any thinking budget) but never exceed wire_max_tokens,
|
|
1053
|
+
# which already respects the model's hard output cap — below the
|
|
1054
|
+
# floor, the provider verdict is the real answer.
|
|
1055
|
+
room = self._profile.max_context_tokens - estimated_input_tokens
|
|
1056
|
+
thinking = self._thinking_budget_tokens()
|
|
1057
|
+
min_send = thinking + _MIN_NOTE_TOKENS if thinking > 0 else _MIN_NOTE_TOKENS
|
|
1058
|
+
if room < send_max_tokens:
|
|
1059
|
+
send_max_tokens = min(wire_max_tokens, max(min_send, room))
|
|
1060
|
+
if send_max_tokens < wire_max_tokens:
|
|
1061
|
+
# Re-render the terminal candidate so the in-prompt length
|
|
1062
|
+
# directive matches the clamped wire budget — otherwise the
|
|
1063
|
+
# model is told it may write far more than max_tokens allows
|
|
1064
|
+
# and the note risks mid-section truncation. The clamped
|
|
1065
|
+
# budget only shrinks the rendered text, so the admission
|
|
1066
|
+
# estimate used for the clamp stays an upper bound.
|
|
1067
|
+
clamped_note_budget = max(1, send_max_tokens - thinking) if thinking > 0 else send_max_tokens
|
|
1068
|
+
candidate = _fallback_candidates(
|
|
1069
|
+
scoped_groups,
|
|
1070
|
+
previous_last_words,
|
|
1071
|
+
template=self._template,
|
|
1072
|
+
degraded_opener=degraded_opener,
|
|
1073
|
+
has_continuation_nudges=has_continuation_nudges,
|
|
1074
|
+
note_budget=clamped_note_budget,
|
|
1075
|
+
format_correction=format_correction,
|
|
1076
|
+
)[-1]
|
|
1077
|
+
estimated_input_tokens = _fallback_admission_tokens(candidate.messages, output_reserve=0)
|
|
1078
|
+
_log.warning(
|
|
1079
|
+
"LAST_WORDS final fallback candidate '%s' exceeds the conservative admission estimate "
|
|
1080
|
+
"(%d > %d tokens); sending it anyway with max_tokens=%d because the provider "
|
|
1081
|
+
"tokenizer is authoritative",
|
|
1082
|
+
candidate.stage,
|
|
1083
|
+
admitted_tokens,
|
|
1084
|
+
self._profile.max_context_tokens,
|
|
1085
|
+
send_max_tokens,
|
|
1086
|
+
)
|
|
1087
|
+
if spend_side_call_tokens is not None and not spend_side_call_tokens(estimated_input_tokens):
|
|
1088
|
+
if accepted := self._accept_pending_format_note(
|
|
1089
|
+
format_state,
|
|
1090
|
+
reason="side-call spend budget stopped the corrective retry",
|
|
1091
|
+
):
|
|
1092
|
+
return accepted
|
|
1093
|
+
raise LastWordsSpendBudgetExceeded("Phase-4 side-call token budget exhausted before fallback attempt")
|
|
1094
|
+
try:
|
|
1095
|
+
output_text = await self._generate_once(list(candidate.messages), max_tokens=send_max_tokens)
|
|
1096
|
+
except Exception as exc:
|
|
1097
|
+
# A false positive only shrinks the candidate; a miss would resend
|
|
1098
|
+
# the same oversized candidate until the retry budget runs out.
|
|
1099
|
+
if may_be_context_overflow(exc):
|
|
1100
|
+
last_context_error = exc
|
|
1101
|
+
candidate_index += 1
|
|
1102
|
+
_log.warning(
|
|
1103
|
+
"LAST_WORDS fallback candidate '%s' hit a provider context rejection; shrinking: %s",
|
|
1104
|
+
candidate.stage,
|
|
1105
|
+
clean_error_message(exc),
|
|
1106
|
+
)
|
|
1107
|
+
self._write_log(
|
|
1108
|
+
candidate.instruction,
|
|
1109
|
+
"",
|
|
1110
|
+
"",
|
|
1111
|
+
error=f"provider context rejection at {candidate.stage}; advancing shrink sequence: {exc}",
|
|
1112
|
+
request_note=f"fallback stage: {candidate.stage}",
|
|
1113
|
+
)
|
|
1114
|
+
continue
|
|
1115
|
+
if transient_attempt >= max_transient_retries or not is_retryable(exc):
|
|
1116
|
+
if accepted := self._accept_pending_format_note(
|
|
1117
|
+
format_state,
|
|
1118
|
+
reason="corrective retry failed at the provider",
|
|
1119
|
+
):
|
|
1120
|
+
return accepted
|
|
1121
|
+
_log.warning("LastWordsGenerator LLM call failed after local retries: %s", exc)
|
|
1122
|
+
self._write_log(
|
|
1123
|
+
candidate.instruction,
|
|
1124
|
+
"",
|
|
1125
|
+
"",
|
|
1126
|
+
error=str(exc),
|
|
1127
|
+
request_note=f"fallback stage: {candidate.stage}",
|
|
1128
|
+
)
|
|
1129
|
+
raise LastWordsGenerationError("Failed to generate last words for the current task") from exc
|
|
1130
|
+
delay = self._delay(transient_attempt)
|
|
1131
|
+
_log.warning(
|
|
1132
|
+
"LastWordsGenerator LLM call failed, retrying compaction request in %gs (attempt %d/%d): %s",
|
|
1133
|
+
delay,
|
|
1134
|
+
transient_attempt + 1,
|
|
1135
|
+
max_transient_retries,
|
|
1136
|
+
clean_error_message(exc),
|
|
1137
|
+
)
|
|
1138
|
+
self._write_log(
|
|
1139
|
+
candidate.instruction,
|
|
1140
|
+
"",
|
|
1141
|
+
"",
|
|
1142
|
+
error=f"attempt {transient_attempt + 1}/{max_transient_retries} failed, retrying: {exc}",
|
|
1143
|
+
request_note=f"fallback stage: {candidate.stage}",
|
|
1144
|
+
)
|
|
1145
|
+
await self._publish_retry_attempt(
|
|
1146
|
+
clean_error_message(exc),
|
|
1147
|
+
transient_attempt + 1,
|
|
1148
|
+
max_transient_retries,
|
|
1149
|
+
delay,
|
|
1150
|
+
)
|
|
1151
|
+
transient_attempt += 1
|
|
1152
|
+
await self._sleep_for_retry(delay, reason_code=RetryReason.TRANSIENT_ERROR)
|
|
1153
|
+
continue
|
|
1154
|
+
|
|
1155
|
+
format_violation: str | None = None
|
|
1156
|
+
repeated_violation = False
|
|
1157
|
+
canonical_text = output_text
|
|
1158
|
+
if output_text:
|
|
1159
|
+
violation, canonical_text = _validate_note_format(output_text)
|
|
1160
|
+
if violation is not None:
|
|
1161
|
+
format_violation = _bounded_format_violation(violation)
|
|
1162
|
+
repeated_violation = format_violation in format_state.violations
|
|
1163
|
+
if not repeated_violation:
|
|
1164
|
+
format_state.violations.append(format_violation)
|
|
1165
|
+
format_state.correction_violation = format_violation
|
|
1166
|
+
else:
|
|
1167
|
+
# A non-empty response with valid structure proves the last
|
|
1168
|
+
# correction was followed. A remaining length shortfall may
|
|
1169
|
+
# still retry, but it no longer needs stale format feedback.
|
|
1170
|
+
format_state.correction_violation = ""
|
|
1171
|
+
shortfall = self._note_shortfall(canonical_text)
|
|
1172
|
+
if shortfall is None:
|
|
1173
|
+
if format_violation is not None:
|
|
1174
|
+
format_state.pending_note = _GeneratedNote(output_text, format_violation=format_violation)
|
|
1175
|
+
format_state.pending_instruction = candidate.instruction
|
|
1176
|
+
format_state.pending_request_note = f"fallback stage: {candidate.stage}"
|
|
1177
|
+
if repeated_violation or corrective_attempt >= max_corrective_retries:
|
|
1178
|
+
reason = (
|
|
1179
|
+
"identical violation repeated"
|
|
1180
|
+
if repeated_violation
|
|
1181
|
+
else "corrective retry budget was exhausted"
|
|
1182
|
+
)
|
|
1183
|
+
accepted = self._accept_pending_format_note(format_state, reason=reason)
|
|
1184
|
+
if accepted is None:
|
|
1185
|
+
raise RuntimeError("LAST_WORDS format candidate disappeared before acceptance")
|
|
1186
|
+
return accepted
|
|
1187
|
+
delay = self._delay(corrective_attempt)
|
|
1188
|
+
_log.warning(
|
|
1189
|
+
"LAST_WORDS format violation, retrying compaction request in %gs (attempt %d/%d): %s",
|
|
1190
|
+
delay,
|
|
1191
|
+
corrective_attempt + 1,
|
|
1192
|
+
max_corrective_retries,
|
|
1193
|
+
format_violation,
|
|
1194
|
+
)
|
|
1195
|
+
self._write_log(
|
|
1196
|
+
candidate.instruction,
|
|
1197
|
+
output_text,
|
|
1198
|
+
"",
|
|
1199
|
+
error=(
|
|
1200
|
+
f"attempt {corrective_attempt + 1}/{max_corrective_retries} returned format violation "
|
|
1201
|
+
f"{format_violation}, retrying"
|
|
1202
|
+
),
|
|
1203
|
+
request_note=f"fallback stage: {candidate.stage}",
|
|
1204
|
+
)
|
|
1205
|
+
await self._publish_retry_attempt(
|
|
1206
|
+
format_violation,
|
|
1207
|
+
corrective_attempt + 1,
|
|
1208
|
+
max_corrective_retries,
|
|
1209
|
+
delay,
|
|
1210
|
+
)
|
|
1211
|
+
corrective_attempt += 1
|
|
1212
|
+
await self._sleep_for_retry(delay, reason_code=RetryReason.VALIDATION_REJECTED)
|
|
1213
|
+
continue
|
|
1214
|
+
self._write_log(
|
|
1215
|
+
candidate.instruction,
|
|
1216
|
+
output_text,
|
|
1217
|
+
canonical_text,
|
|
1218
|
+
request_note=f"fallback stage: {candidate.stage}",
|
|
1219
|
+
)
|
|
1220
|
+
return _GeneratedNote(canonical_text)
|
|
1221
|
+
|
|
1222
|
+
if corrective_attempt >= max_corrective_retries:
|
|
1223
|
+
if format_state.pending_note is not None:
|
|
1224
|
+
self._write_log(
|
|
1225
|
+
candidate.instruction,
|
|
1226
|
+
output_text,
|
|
1227
|
+
"",
|
|
1228
|
+
error=f"{shortfall} after retries; accepting the earlier complete malformed note",
|
|
1229
|
+
request_note=f"fallback stage: {candidate.stage}",
|
|
1230
|
+
)
|
|
1231
|
+
accepted = self._accept_pending_format_note(
|
|
1232
|
+
format_state,
|
|
1233
|
+
reason=f"{shortfall} and corrective retry budget was exhausted",
|
|
1234
|
+
)
|
|
1235
|
+
if accepted is None:
|
|
1236
|
+
raise RuntimeError("LAST_WORDS format candidate disappeared before acceptance")
|
|
1237
|
+
return accepted
|
|
1238
|
+
if output_text:
|
|
1239
|
+
accepted_error = f"{shortfall} after retries, accepted"
|
|
1240
|
+
if format_violation is not None:
|
|
1241
|
+
format_state.pending_note = _GeneratedNote(output_text, format_violation=format_violation)
|
|
1242
|
+
format_state.pending_instruction = candidate.instruction
|
|
1243
|
+
format_state.pending_request_note = f"fallback stage: {candidate.stage}"
|
|
1244
|
+
accepted = self._accept_pending_format_note(
|
|
1245
|
+
format_state,
|
|
1246
|
+
reason=f"{shortfall} and corrective retry budget was exhausted",
|
|
1247
|
+
)
|
|
1248
|
+
if accepted is None:
|
|
1249
|
+
raise RuntimeError("LAST_WORDS format candidate disappeared before acceptance")
|
|
1250
|
+
return accepted
|
|
1251
|
+
_log.warning(
|
|
1252
|
+
"LastWordsGenerator note still %s after local retries; accepting the short note",
|
|
1253
|
+
shortfall,
|
|
1254
|
+
)
|
|
1255
|
+
self._write_log(
|
|
1256
|
+
candidate.instruction,
|
|
1257
|
+
output_text,
|
|
1258
|
+
canonical_text,
|
|
1259
|
+
error=accepted_error,
|
|
1260
|
+
request_note=f"fallback stage: {candidate.stage}",
|
|
1261
|
+
)
|
|
1262
|
+
return _GeneratedNote(canonical_text, format_violation=format_violation or "")
|
|
1263
|
+
if accepted := self._accept_pending_format_note(
|
|
1264
|
+
format_state,
|
|
1265
|
+
reason="corrective retry budget was exhausted after an empty response",
|
|
1266
|
+
):
|
|
1267
|
+
return accepted
|
|
1268
|
+
_log.warning("LastWordsGenerator returned %s after local retries", shortfall)
|
|
1269
|
+
self._write_log(
|
|
1270
|
+
candidate.instruction,
|
|
1271
|
+
output_text,
|
|
1272
|
+
"",
|
|
1273
|
+
error=shortfall,
|
|
1274
|
+
request_note=f"fallback stage: {candidate.stage}",
|
|
1275
|
+
)
|
|
1276
|
+
raise LastWordsGenerationError("Failed to generate last words for the current task")
|
|
1277
|
+
delay = self._delay(corrective_attempt)
|
|
1278
|
+
_log.warning(
|
|
1279
|
+
"LastWordsGenerator returned %s, retrying compaction request in %gs (attempt %d/%d)",
|
|
1280
|
+
shortfall,
|
|
1281
|
+
delay,
|
|
1282
|
+
corrective_attempt + 1,
|
|
1283
|
+
max_corrective_retries,
|
|
1284
|
+
)
|
|
1285
|
+
self._write_log(
|
|
1286
|
+
candidate.instruction,
|
|
1287
|
+
output_text,
|
|
1288
|
+
"",
|
|
1289
|
+
error=f"attempt {corrective_attempt + 1}/{max_corrective_retries} returned {shortfall}, retrying",
|
|
1290
|
+
request_note=f"fallback stage: {candidate.stage}",
|
|
1291
|
+
)
|
|
1292
|
+
await self._publish_retry_attempt(
|
|
1293
|
+
shortfall,
|
|
1294
|
+
corrective_attempt + 1,
|
|
1295
|
+
max_corrective_retries,
|
|
1296
|
+
delay,
|
|
1297
|
+
)
|
|
1298
|
+
corrective_attempt += 1
|
|
1299
|
+
await self._sleep_for_retry(delay, reason_code=RetryReason.VALIDATION_REJECTED)
|
|
1300
|
+
|
|
1301
|
+
async def _generate_via_completer(
|
|
1302
|
+
self,
|
|
1303
|
+
completer: LastWordsCompleter,
|
|
1304
|
+
scoped_groups: list[ScopedGroup],
|
|
1305
|
+
*,
|
|
1306
|
+
tokenizer: TokenizerProtocol,
|
|
1307
|
+
has_continuation_nudges: bool,
|
|
1308
|
+
system_overhead_tokens: int,
|
|
1309
|
+
tool_definition_tokens: int,
|
|
1310
|
+
request_overhead_tokens: int,
|
|
1311
|
+
calibration_ratio: float,
|
|
1312
|
+
spend_side_call_tokens: Callable[[int], bool] | None,
|
|
1313
|
+
format_state: _FormatRetryState,
|
|
1314
|
+
) -> _GeneratedNote | None:
|
|
1315
|
+
"""Run the physically scoped side call within its global input budget.
|
|
1316
|
+
|
|
1317
|
+
Returns the generated note or ``None`` to demote. ``format_state``
|
|
1318
|
+
carries corrective-retry state across the fallback boundary:
|
|
1319
|
+
|
|
1320
|
+
* provider context-window rejection of the side call — retrying the
|
|
1321
|
+
same oversized scoped request cannot succeed;
|
|
1322
|
+
* any other non-retryable failure;
|
|
1323
|
+
* exhaustion of the local retry budget on transient transport
|
|
1324
|
+
errors, repeated illegal tool-call responses
|
|
1325
|
+
(:class:`~chrys.kernel.LastWordsToolCallError` from the client's
|
|
1326
|
+
response guard), or empty/too-short responses (``_note_shortfall``).
|
|
1327
|
+
|
|
1328
|
+
Provider failures demote to fallback. The only direct exception is
|
|
1329
|
+
:class:`LastWordsSpendBudgetExceeded`, raised before an over-budget
|
|
1330
|
+
provider attempt when no fresh malformed note is available to accept.
|
|
1331
|
+
"""
|
|
1332
|
+
note_budget, wire_max_tokens = self._output_budgets()
|
|
1333
|
+
# ``system_overhead_tokens`` is the calibrated fixed request overhead.
|
|
1334
|
+
# The explicit request/tool estimates are floors before calibration or
|
|
1335
|
+
# when provider framing is undercounted; adding them would double-charge
|
|
1336
|
+
# overlapping content.
|
|
1337
|
+
fixed_request_overhead_tokens = max(
|
|
1338
|
+
0,
|
|
1339
|
+
system_overhead_tokens,
|
|
1340
|
+
request_overhead_tokens,
|
|
1341
|
+
tool_definition_tokens,
|
|
1342
|
+
)
|
|
1343
|
+
for attempt in range(_COMPLETER_MAX_RETRIES + 1):
|
|
1344
|
+
format_correction = (
|
|
1345
|
+
_format_correction(format_state.correction_violation) if format_state.correction_violation else ""
|
|
1346
|
+
)
|
|
1347
|
+
instruction = _completer_instruction(
|
|
1348
|
+
self._template,
|
|
1349
|
+
has_continuation_nudges=has_continuation_nudges,
|
|
1350
|
+
max_output_tokens=note_budget,
|
|
1351
|
+
format_correction=format_correction,
|
|
1352
|
+
)
|
|
1353
|
+
instruction_tokens = tokenizer.count_tokens(instruction)
|
|
1354
|
+
usable_raw = math.floor(
|
|
1355
|
+
(self._profile.max_context_tokens - wire_max_tokens - _SLICE_SAFETY_MARGIN_TOKENS) / calibration_ratio
|
|
1356
|
+
)
|
|
1357
|
+
slice_budget = usable_raw - fixed_request_overhead_tokens - instruction_tokens
|
|
1358
|
+
prepared = prepare_scoped_slice(scoped_groups, tokenizer=tokenizer, slice_budget=slice_budget)
|
|
1359
|
+
if prepared is None:
|
|
1360
|
+
_log.warning(
|
|
1361
|
+
"LAST_WORDS scoped slice failed budget or runtime shape validation; "
|
|
1362
|
+
"demoting to fallback (budget=%d)",
|
|
1363
|
+
slice_budget,
|
|
1364
|
+
)
|
|
1365
|
+
return None
|
|
1366
|
+
base_messages = prepared.messages
|
|
1367
|
+
prompt_note = (
|
|
1368
|
+
f"(physically scoped side call: {len(base_messages)} messages, "
|
|
1369
|
+
f"{prepared.estimated_tokens} estimated message tokens, {prepared.clipped_results} clipped results, "
|
|
1370
|
+
f"{prepared.clipped_assistant_texts} clipped assistant texts)"
|
|
1371
|
+
)
|
|
1372
|
+
estimated_input_tokens = prepared.estimated_tokens + instruction_tokens + fixed_request_overhead_tokens
|
|
1373
|
+
if spend_side_call_tokens is not None and not spend_side_call_tokens(estimated_input_tokens):
|
|
1374
|
+
if accepted := self._accept_pending_format_note(
|
|
1375
|
+
format_state,
|
|
1376
|
+
reason="side-call spend budget stopped the corrective retry",
|
|
1377
|
+
):
|
|
1378
|
+
return accepted
|
|
1379
|
+
raise LastWordsSpendBudgetExceeded("Phase-4 side-call token budget exhausted before completer attempt")
|
|
1380
|
+
try:
|
|
1381
|
+
output_text = _normalize_note_response(
|
|
1382
|
+
await completer.complete_last_words(
|
|
1383
|
+
base_messages,
|
|
1384
|
+
instruction,
|
|
1385
|
+
max_output_tokens=wire_max_tokens,
|
|
1386
|
+
on_usage=self._report_side_call_usage,
|
|
1387
|
+
)
|
|
1388
|
+
)
|
|
1389
|
+
except Exception as exc:
|
|
1390
|
+
if may_be_context_overflow(exc):
|
|
1391
|
+
_log.warning(
|
|
1392
|
+
"LAST_WORDS side call rejected for context-window size, demoting to fallback: %s",
|
|
1393
|
+
clean_error_message(exc),
|
|
1394
|
+
)
|
|
1395
|
+
self._write_log(
|
|
1396
|
+
instruction,
|
|
1397
|
+
"",
|
|
1398
|
+
"",
|
|
1399
|
+
error=f"context-window rejection, demoting to fallback: {exc}",
|
|
1400
|
+
request_note=prompt_note,
|
|
1401
|
+
)
|
|
1402
|
+
return None
|
|
1403
|
+
retryable = is_retryable(exc) or isinstance(exc, LastWordsToolCallError)
|
|
1404
|
+
if attempt >= _COMPLETER_MAX_RETRIES or not retryable:
|
|
1405
|
+
_log.warning("LAST_WORDS side call failed after local retries, demoting to fallback: %s", exc)
|
|
1406
|
+
self._write_log(
|
|
1407
|
+
instruction,
|
|
1408
|
+
"",
|
|
1409
|
+
"",
|
|
1410
|
+
error=f"side call failed, demoting to fallback: {exc}",
|
|
1411
|
+
request_note=prompt_note,
|
|
1412
|
+
)
|
|
1413
|
+
return None
|
|
1414
|
+
delay = self._delay(attempt)
|
|
1415
|
+
_log.warning(
|
|
1416
|
+
"LAST_WORDS side call failed, retrying in %gs (attempt %d/%d): %s",
|
|
1417
|
+
delay,
|
|
1418
|
+
attempt + 1,
|
|
1419
|
+
_COMPLETER_MAX_RETRIES,
|
|
1420
|
+
clean_error_message(exc),
|
|
1421
|
+
)
|
|
1422
|
+
self._write_log(
|
|
1423
|
+
instruction,
|
|
1424
|
+
"",
|
|
1425
|
+
"",
|
|
1426
|
+
error=f"attempt {attempt + 1}/{_COMPLETER_MAX_RETRIES} failed, retrying: {exc}",
|
|
1427
|
+
request_note=prompt_note,
|
|
1428
|
+
)
|
|
1429
|
+
await self._publish_retry_attempt(
|
|
1430
|
+
clean_error_message(exc),
|
|
1431
|
+
attempt + 1,
|
|
1432
|
+
_COMPLETER_MAX_RETRIES,
|
|
1433
|
+
delay,
|
|
1434
|
+
)
|
|
1435
|
+
await self._sleep_for_retry(delay, reason_code=RetryReason.TRANSIENT_ERROR)
|
|
1436
|
+
continue
|
|
1437
|
+
|
|
1438
|
+
format_violation: str | None = None
|
|
1439
|
+
repeated_violation = False
|
|
1440
|
+
canonical_text = output_text
|
|
1441
|
+
if output_text:
|
|
1442
|
+
violation, canonical_text = _validate_note_format(output_text)
|
|
1443
|
+
if violation is not None:
|
|
1444
|
+
format_violation = _bounded_format_violation(violation)
|
|
1445
|
+
repeated_violation = format_violation in format_state.violations
|
|
1446
|
+
if not repeated_violation:
|
|
1447
|
+
format_state.violations.append(format_violation)
|
|
1448
|
+
format_state.correction_violation = format_violation
|
|
1449
|
+
else:
|
|
1450
|
+
format_state.correction_violation = ""
|
|
1451
|
+
shortfall = self._note_shortfall(canonical_text)
|
|
1452
|
+
if shortfall is None:
|
|
1453
|
+
if format_violation is not None:
|
|
1454
|
+
format_state.pending_note = _GeneratedNote(output_text, format_violation=format_violation)
|
|
1455
|
+
format_state.pending_instruction = instruction
|
|
1456
|
+
format_state.pending_request_note = prompt_note
|
|
1457
|
+
if repeated_violation:
|
|
1458
|
+
accepted = self._accept_pending_format_note(
|
|
1459
|
+
format_state,
|
|
1460
|
+
reason="identical violation repeated",
|
|
1461
|
+
)
|
|
1462
|
+
if accepted is None:
|
|
1463
|
+
raise RuntimeError("LAST_WORDS format candidate disappeared before acceptance")
|
|
1464
|
+
return accepted
|
|
1465
|
+
if attempt >= _COMPLETER_MAX_RETRIES:
|
|
1466
|
+
_log.warning(
|
|
1467
|
+
"LAST_WORDS side call returned a fresh format violation at its retry limit; "
|
|
1468
|
+
"demoting to fallback: %s",
|
|
1469
|
+
format_violation,
|
|
1470
|
+
)
|
|
1471
|
+
self._write_log(
|
|
1472
|
+
instruction,
|
|
1473
|
+
output_text,
|
|
1474
|
+
"",
|
|
1475
|
+
error=f"format violation {format_violation}, demoting to fallback for corrective retry",
|
|
1476
|
+
request_note=prompt_note,
|
|
1477
|
+
)
|
|
1478
|
+
return None
|
|
1479
|
+
delay = self._delay(attempt)
|
|
1480
|
+
_log.warning(
|
|
1481
|
+
"LAST_WORDS side call returned a format violation, retrying in %gs (attempt %d/%d): %s",
|
|
1482
|
+
delay,
|
|
1483
|
+
attempt + 1,
|
|
1484
|
+
_COMPLETER_MAX_RETRIES,
|
|
1485
|
+
format_violation,
|
|
1486
|
+
)
|
|
1487
|
+
self._write_log(
|
|
1488
|
+
instruction,
|
|
1489
|
+
output_text,
|
|
1490
|
+
"",
|
|
1491
|
+
error=(
|
|
1492
|
+
f"attempt {attempt + 1}/{_COMPLETER_MAX_RETRIES} returned format violation "
|
|
1493
|
+
f"{format_violation}, retrying"
|
|
1494
|
+
),
|
|
1495
|
+
request_note=prompt_note,
|
|
1496
|
+
)
|
|
1497
|
+
await self._publish_retry_attempt(
|
|
1498
|
+
format_violation,
|
|
1499
|
+
attempt + 1,
|
|
1500
|
+
_COMPLETER_MAX_RETRIES,
|
|
1501
|
+
delay,
|
|
1502
|
+
)
|
|
1503
|
+
await self._sleep_for_retry(delay, reason_code=RetryReason.VALIDATION_REJECTED)
|
|
1504
|
+
continue
|
|
1505
|
+
self._write_log(
|
|
1506
|
+
instruction,
|
|
1507
|
+
output_text,
|
|
1508
|
+
canonical_text,
|
|
1509
|
+
request_note=prompt_note,
|
|
1510
|
+
)
|
|
1511
|
+
return _GeneratedNote(canonical_text)
|
|
1512
|
+
|
|
1513
|
+
if attempt >= _COMPLETER_MAX_RETRIES:
|
|
1514
|
+
_log.warning("LAST_WORDS side call returned %s after local retries, demoting to fallback", shortfall)
|
|
1515
|
+
self._write_log(
|
|
1516
|
+
instruction,
|
|
1517
|
+
output_text,
|
|
1518
|
+
"",
|
|
1519
|
+
error=f"{shortfall}, demoting to fallback",
|
|
1520
|
+
request_note=prompt_note,
|
|
1521
|
+
)
|
|
1522
|
+
return None
|
|
1523
|
+
delay = self._delay(attempt)
|
|
1524
|
+
_log.warning(
|
|
1525
|
+
"LAST_WORDS side call returned %s, retrying in %gs (attempt %d/%d)",
|
|
1526
|
+
shortfall,
|
|
1527
|
+
delay,
|
|
1528
|
+
attempt + 1,
|
|
1529
|
+
_COMPLETER_MAX_RETRIES,
|
|
1530
|
+
)
|
|
1531
|
+
self._write_log(
|
|
1532
|
+
instruction,
|
|
1533
|
+
output_text,
|
|
1534
|
+
"",
|
|
1535
|
+
error=f"attempt {attempt + 1}/{_COMPLETER_MAX_RETRIES} returned {shortfall}, retrying",
|
|
1536
|
+
request_note=prompt_note,
|
|
1537
|
+
)
|
|
1538
|
+
await self._publish_retry_attempt(shortfall, attempt + 1, _COMPLETER_MAX_RETRIES, delay)
|
|
1539
|
+
await self._sleep_for_retry(delay, reason_code=RetryReason.VALIDATION_REJECTED)
|
|
1540
|
+
|
|
1541
|
+
return None
|
|
1542
|
+
|
|
1543
|
+
def _accept_pending_format_note(
|
|
1544
|
+
self,
|
|
1545
|
+
state: _FormatRetryState,
|
|
1546
|
+
*,
|
|
1547
|
+
reason: str,
|
|
1548
|
+
) -> _GeneratedNote | None:
|
|
1549
|
+
"""Accept a fresh malformed note when bounded correction cannot continue."""
|
|
1550
|
+
note = state.pending_note
|
|
1551
|
+
if note is None:
|
|
1552
|
+
return None
|
|
1553
|
+
_log.warning(
|
|
1554
|
+
"LAST_WORDS format violation accepted when corrective retries stopped (%s): %s",
|
|
1555
|
+
reason,
|
|
1556
|
+
note.format_violation,
|
|
1557
|
+
)
|
|
1558
|
+
self._write_log(
|
|
1559
|
+
state.pending_instruction,
|
|
1560
|
+
note.text,
|
|
1561
|
+
note.text,
|
|
1562
|
+
error=f"format violation accepted: {note.format_violation}; corrective retries stopped: {reason}",
|
|
1563
|
+
request_note=state.pending_request_note or None,
|
|
1564
|
+
)
|
|
1565
|
+
return note
|
|
1566
|
+
|
|
1567
|
+
async def _publish_retry_attempt(self, reason: str, attempt: int, max_attempts: int, delay_seconds: float) -> None:
|
|
1568
|
+
if self._publish_retry is None:
|
|
1569
|
+
return
|
|
1570
|
+
try:
|
|
1571
|
+
await self._publish_retry(
|
|
1572
|
+
RetryAttemptInfo(
|
|
1573
|
+
reason=reason,
|
|
1574
|
+
attempt=attempt,
|
|
1575
|
+
max_attempts=max_attempts,
|
|
1576
|
+
delay_seconds=delay_seconds,
|
|
1577
|
+
)
|
|
1578
|
+
)
|
|
1579
|
+
except Exception:
|
|
1580
|
+
_log.debug("Failed to publish LAST_WORDS retry event", exc_info=True)
|
|
1581
|
+
|
|
1582
|
+
async def _sleep_for_retry(self, delay_seconds: float, *, reason_code: str) -> None:
|
|
1583
|
+
trace = RetryBackoffTrace.open(
|
|
1584
|
+
parent_operation_id=current_compaction_operation_id(),
|
|
1585
|
+
retry_mode=RetryMode.COMPACTION,
|
|
1586
|
+
)
|
|
1587
|
+
if trace is not None:
|
|
1588
|
+
await trace.scheduled(reason_code=reason_code, delay_seconds=delay_seconds)
|
|
1589
|
+
await asyncio.sleep(delay_seconds)
|
|
1590
|
+
if trace is not None:
|
|
1591
|
+
await trace.started()
|
|
1592
|
+
|
|
1593
|
+
def _delay(self, attempt: int) -> float:
|
|
1594
|
+
schedule = type(self)._BACKOFF_SCHEDULE
|
|
1595
|
+
if not schedule:
|
|
1596
|
+
return 0
|
|
1597
|
+
return schedule[min(attempt, len(schedule) - 1)]
|
|
1598
|
+
|
|
1599
|
+
def _note_shortfall(self, text: str) -> str | None:
|
|
1600
|
+
"""Why ``text`` is unusable as a note (``None`` when acceptable).
|
|
1601
|
+
|
|
1602
|
+
Non-empty is not enough: a truncated fragment silently replaces the
|
|
1603
|
+
whole dropped turn, so anything under ``_MIN_NOTE_CHARS`` is retried
|
|
1604
|
+
like an empty response. Callers pass the *canonical* note (violating
|
|
1605
|
+
responses come through unchanged — the validator returns them as-is)
|
|
1606
|
+
and the floor is measured on whitespace-collapsed content: anything
|
|
1607
|
+
canonicalization strips (blank-line padding, heading closing
|
|
1608
|
+
sequences) or collapsing removes must not clear the floor only for
|
|
1609
|
+
the accepted note to shrink below it and authorize dropping the
|
|
1610
|
+
current turn.
|
|
1611
|
+
"""
|
|
1612
|
+
substantive_chars = len("".join(text.split()))
|
|
1613
|
+
if not substantive_chars:
|
|
1614
|
+
return "empty response"
|
|
1615
|
+
floor = type(self)._MIN_NOTE_CHARS
|
|
1616
|
+
if substantive_chars < floor:
|
|
1617
|
+
return f"note too short ({substantive_chars} substantive chars < {floor} minimum)"
|
|
1618
|
+
return None
|
|
1619
|
+
|
|
1620
|
+
async def _generate_once(self, messages: list[Message], *, max_tokens: int) -> str:
|
|
1621
|
+
client = await self._get_client()
|
|
1622
|
+
profile_options = self._profile_chat_options()
|
|
1623
|
+
options = {key: value for key, value in profile_options.items() if key in _FALLBACK_ALLOWED_OPTION_KEYS}
|
|
1624
|
+
extra_body = profile_options.get("extra_body")
|
|
1625
|
+
if isinstance(extra_body, Mapping) and PROMPT_CACHE_KEY_OPTION in extra_body:
|
|
1626
|
+
# Of extra_body, only the prompt cache key, or its null, applies to the note.
|
|
1627
|
+
options["extra_body"] = {PROMPT_CACHE_KEY_OPTION: extra_body[PROMPT_CACHE_KEY_OPTION]}
|
|
1628
|
+
options["max_tokens"] = max_tokens
|
|
1629
|
+
report_wire_progress()
|
|
1630
|
+
try:
|
|
1631
|
+
with side_call_scope(ActorRole.COMPACTION):
|
|
1632
|
+
response = await get_final_response(
|
|
1633
|
+
client,
|
|
1634
|
+
messages,
|
|
1635
|
+
stream=self._profile.stream,
|
|
1636
|
+
options=options,
|
|
1637
|
+
timeout=self._profile.http_read_timeout,
|
|
1638
|
+
)
|
|
1639
|
+
except ProviderResponseError as err:
|
|
1640
|
+
# A response the adapter failed consumed provider tokens too.
|
|
1641
|
+
if err.usage_details:
|
|
1642
|
+
self._report_side_call_usage(err.usage_details)
|
|
1643
|
+
raise
|
|
1644
|
+
usage_details = response.usage_details
|
|
1645
|
+
if usage_details:
|
|
1646
|
+
self._report_side_call_usage(usage_details)
|
|
1647
|
+
raise_if_context_window_filled(response)
|
|
1648
|
+
return _normalize_note_response(response.raw_text)
|
|
1649
|
+
|
|
1650
|
+
def _write_log(
|
|
1651
|
+
self,
|
|
1652
|
+
instruction: str,
|
|
1653
|
+
raw_response: str,
|
|
1654
|
+
final_text: str,
|
|
1655
|
+
error: str | None = None,
|
|
1656
|
+
request_note: str | None = None,
|
|
1657
|
+
) -> None:
|
|
1658
|
+
"""Write one side-call debug log.
|
|
1659
|
+
|
|
1660
|
+
Deliberately excludes the user/timeline payload (user messages, tool
|
|
1661
|
+
calls, previous note) — session data lives in the spill records. The
|
|
1662
|
+
log keeps only the instruction actually sent, the model's raw
|
|
1663
|
+
response, and the text placed in the LAST_WORDS tag for the next
|
|
1664
|
+
turn, plus a small request summary when provided.
|
|
1665
|
+
"""
|
|
1666
|
+
if self._log_dir is None:
|
|
1667
|
+
return
|
|
1668
|
+
try:
|
|
1669
|
+
self._log_dir.mkdir(parents=True, exist_ok=True)
|
|
1670
|
+
# Salt with a short uuid fragment: concurrent Phase-3 rounds
|
|
1671
|
+
# in the same millisecond (e.g. main agent + sub-agent) would
|
|
1672
|
+
# otherwise overwrite each other's log.
|
|
1673
|
+
ts = datetime.now(UTC).strftime("%H%M%S_%f")[:-3]
|
|
1674
|
+
path = self._log_dir / f"last_words_{ts}_{uuid4().hex[:6]}.log"
|
|
1675
|
+
lines: list[str] = []
|
|
1676
|
+
lines.append("=" * 78)
|
|
1677
|
+
lines.append(f"LAST_WORDS @ {datetime.now(UTC).isoformat()}")
|
|
1678
|
+
lines.append("=" * 78)
|
|
1679
|
+
if request_note:
|
|
1680
|
+
lines.append("")
|
|
1681
|
+
lines.append("--- REQUEST ---")
|
|
1682
|
+
lines.append(request_note)
|
|
1683
|
+
lines.append("")
|
|
1684
|
+
lines.append("--- INSTRUCTION ---")
|
|
1685
|
+
lines.append(instruction.rstrip() or "(none)")
|
|
1686
|
+
lines.append("")
|
|
1687
|
+
lines.append("--- RAW RESPONSE ---")
|
|
1688
|
+
lines.append(raw_response.rstrip() if raw_response else "(empty)")
|
|
1689
|
+
if error:
|
|
1690
|
+
lines.append("")
|
|
1691
|
+
lines.append("--- ERROR ---")
|
|
1692
|
+
lines.append(error)
|
|
1693
|
+
lines.append("")
|
|
1694
|
+
lines.append("--- LAST_WORDS ---")
|
|
1695
|
+
lines.append(final_text.rstrip() or "(none)")
|
|
1696
|
+
lines.append("")
|
|
1697
|
+
# Owner-only like the spill records: the log carries the note
|
|
1698
|
+
# text and raw model output, which summarise session content.
|
|
1699
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
1700
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
1701
|
+
output.write("\n".join(lines))
|
|
1702
|
+
except Exception:
|
|
1703
|
+
_log.debug("Failed to write last_words log", exc_info=True)
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
@dataclass(frozen=True, slots=True)
|
|
1707
|
+
class _TimelineItem:
|
|
1708
|
+
text: str
|
|
1709
|
+
tool_calls: int = 0
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
@dataclass(frozen=True, slots=True)
|
|
1713
|
+
class _FallbackSpec:
|
|
1714
|
+
stage: str
|
|
1715
|
+
timeline_ceiling: int
|
|
1716
|
+
previous_note_cap: int
|
|
1717
|
+
supplement_cap: int = _FALLBACK_TEMPLATE_MAX_CHARS
|
|
1718
|
+
newest_few: bool = False
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
@dataclass(frozen=True, slots=True)
|
|
1722
|
+
class _FallbackCandidate:
|
|
1723
|
+
stage: str
|
|
1724
|
+
messages: tuple[Message, Message]
|
|
1725
|
+
instruction: str
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _fallback_candidates(
|
|
1729
|
+
scoped_groups: list[ScopedGroup],
|
|
1730
|
+
previous_last_words: str | None,
|
|
1731
|
+
*,
|
|
1732
|
+
template: str,
|
|
1733
|
+
degraded_opener: bool,
|
|
1734
|
+
has_continuation_nudges: bool,
|
|
1735
|
+
note_budget: int,
|
|
1736
|
+
format_correction: str,
|
|
1737
|
+
) -> list[_FallbackCandidate]:
|
|
1738
|
+
half_timeline = max(1, _FALLBACK_MAX_CHARS // 2)
|
|
1739
|
+
half_note = max(1, _FALLBACK_PREV_NOTE_MAX_CHARS // 2)
|
|
1740
|
+
specs = (
|
|
1741
|
+
_FallbackSpec("bounded", _FALLBACK_MAX_CHARS, _FALLBACK_PREV_NOTE_MAX_CHARS),
|
|
1742
|
+
_FallbackSpec("half-timeline", half_timeline, _FALLBACK_PREV_NOTE_MAX_CHARS),
|
|
1743
|
+
_FallbackSpec("half-previous-note", half_timeline, half_note),
|
|
1744
|
+
_FallbackSpec("compact-supplement", half_timeline, half_note, _FALLBACK_TEMPLATE_COMPACT_CHARS),
|
|
1745
|
+
_FallbackSpec(
|
|
1746
|
+
"newest-few",
|
|
1747
|
+
_FALLBACK_MIN_TIMELINE_CHARS,
|
|
1748
|
+
_FALLBACK_MIN_TIMELINE_CHARS,
|
|
1749
|
+
_FALLBACK_TEMPLATE_MIN_CHARS,
|
|
1750
|
+
newest_few=True,
|
|
1751
|
+
),
|
|
1752
|
+
)
|
|
1753
|
+
return [
|
|
1754
|
+
_render_fallback_candidate(
|
|
1755
|
+
scoped_groups,
|
|
1756
|
+
previous_last_words,
|
|
1757
|
+
template=template,
|
|
1758
|
+
degraded_opener=degraded_opener,
|
|
1759
|
+
has_continuation_nudges=has_continuation_nudges,
|
|
1760
|
+
note_budget=note_budget,
|
|
1761
|
+
format_correction=format_correction,
|
|
1762
|
+
spec=spec,
|
|
1763
|
+
)
|
|
1764
|
+
for spec in specs
|
|
1765
|
+
]
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def _render_fallback_candidate(
|
|
1769
|
+
scoped_groups: list[ScopedGroup],
|
|
1770
|
+
previous_last_words: str | None,
|
|
1771
|
+
*,
|
|
1772
|
+
template: str,
|
|
1773
|
+
degraded_opener: bool,
|
|
1774
|
+
has_continuation_nudges: bool,
|
|
1775
|
+
note_budget: int,
|
|
1776
|
+
format_correction: str,
|
|
1777
|
+
spec: _FallbackSpec,
|
|
1778
|
+
) -> _FallbackCandidate:
|
|
1779
|
+
user_request = _timeline_opener(scoped_groups, degraded_opener=degraded_opener)
|
|
1780
|
+
user_request = _truncate_head(user_request, spec.timeline_ceiling)
|
|
1781
|
+
previous_note = _middle_truncate_previous_note(previous_last_words or "(none)", spec.previous_note_cap)
|
|
1782
|
+
work_done = _format_dropped(
|
|
1783
|
+
scoped_groups,
|
|
1784
|
+
max_chars=spec.timeline_ceiling,
|
|
1785
|
+
newest_few=spec.newest_few,
|
|
1786
|
+
)
|
|
1787
|
+
sections = [
|
|
1788
|
+
f"Current turn user request:\n{_prompt_block('user_request', escape_system_reminder_tags(user_request))}",
|
|
1789
|
+
f"Previous progress note:\n{_prompt_block('previous_progress_note', escape_system_reminder_tags(previous_note))}",
|
|
1790
|
+
(
|
|
1791
|
+
"Work done since (about to be dropped from context):\n"
|
|
1792
|
+
f"{_prompt_block('work_done_since', escape_system_reminder_tags(work_done))}"
|
|
1793
|
+
),
|
|
1794
|
+
(
|
|
1795
|
+
"Write the updated progress note now. "
|
|
1796
|
+
f"{_FALLBACK_LENGTH_DIRECTIVE.format(min_note_tokens=_MIN_NOTE_TOKENS, max_output_tokens=note_budget)}"
|
|
1797
|
+
),
|
|
1798
|
+
]
|
|
1799
|
+
user_prompt = "\n\n".join(sections)
|
|
1800
|
+
system_instruction = _fallback_instruction(
|
|
1801
|
+
_truncate_template(template, spec.supplement_cap),
|
|
1802
|
+
has_continuation_nudges=has_continuation_nudges,
|
|
1803
|
+
max_output_tokens=note_budget,
|
|
1804
|
+
format_correction=format_correction,
|
|
1805
|
+
)
|
|
1806
|
+
return _FallbackCandidate(
|
|
1807
|
+
stage=spec.stage,
|
|
1808
|
+
messages=(Message("system", [system_instruction]), Message("user", [user_prompt])),
|
|
1809
|
+
instruction=system_instruction,
|
|
1810
|
+
)
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def _fallback_admission_tokens(messages: tuple[Message, ...], *, output_reserve: int) -> int:
|
|
1814
|
+
"""Conservative whole-request counter for the terminal fallback."""
|
|
1815
|
+
total = max(0, output_reserve)
|
|
1816
|
+
for message in messages:
|
|
1817
|
+
serialized = json.dumps(
|
|
1818
|
+
message.to_dict(),
|
|
1819
|
+
ensure_ascii=False,
|
|
1820
|
+
sort_keys=True,
|
|
1821
|
+
separators=(",", ":"),
|
|
1822
|
+
default=str,
|
|
1823
|
+
)
|
|
1824
|
+
total += len(serialized.encode("utf-8")) + _FALLBACK_FRAMING_TOKENS_PER_MESSAGE
|
|
1825
|
+
return total
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def _timeline_opener(scoped_groups: list[ScopedGroup], *, degraded_opener: bool) -> str:
|
|
1829
|
+
if degraded_opener:
|
|
1830
|
+
return DEGRADED_SCOPED_PREAMBLE
|
|
1831
|
+
for group in scoped_groups:
|
|
1832
|
+
if group.kind != "user":
|
|
1833
|
+
continue
|
|
1834
|
+
for message in group.messages:
|
|
1835
|
+
text = _user_authored_text(message)
|
|
1836
|
+
if text:
|
|
1837
|
+
return text
|
|
1838
|
+
return "(none)"
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _format_dropped(
|
|
1842
|
+
scoped_groups: list[ScopedGroup],
|
|
1843
|
+
*,
|
|
1844
|
+
max_chars: int = _FALLBACK_MAX_CHARS,
|
|
1845
|
+
newest_few: bool = False,
|
|
1846
|
+
) -> str:
|
|
1847
|
+
"""Render one bounded timeline with group-local call/result pairing."""
|
|
1848
|
+
items: list[_TimelineItem] = []
|
|
1849
|
+
opener_seen = False
|
|
1850
|
+
for group in scoped_groups:
|
|
1851
|
+
if group.kind == "user":
|
|
1852
|
+
for message in group.messages:
|
|
1853
|
+
if not opener_seen:
|
|
1854
|
+
opener_seen = True
|
|
1855
|
+
continue
|
|
1856
|
+
if is_continuation_message(message):
|
|
1857
|
+
continue
|
|
1858
|
+
text = _user_authored_text(message)
|
|
1859
|
+
if text:
|
|
1860
|
+
items.append(_TimelineItem(f"- user said: {_truncate_head(text, _FALLBACK_FOLLOWUP_MAX)}"))
|
|
1861
|
+
continue
|
|
1862
|
+
if group.kind == "tool_call":
|
|
1863
|
+
item = _format_tool_group(group)
|
|
1864
|
+
if item is not None:
|
|
1865
|
+
items.append(item)
|
|
1866
|
+
continue
|
|
1867
|
+
for message in group.messages:
|
|
1868
|
+
text = (message.text or "").strip()
|
|
1869
|
+
if text:
|
|
1870
|
+
items.append(_TimelineItem(f"- agent said: {_truncate_head(text, _FALLBACK_ASSISTANT_MAX)}"))
|
|
1871
|
+
return _bound_timeline(items, max_chars=max_chars, newest_few=newest_few)
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def _format_tool_group(group: ScopedGroup) -> _TimelineItem | None:
|
|
1875
|
+
call_info: dict[str, tuple[str, str]] = {}
|
|
1876
|
+
call_order: list[str] = []
|
|
1877
|
+
lines: list[str] = []
|
|
1878
|
+
for message in group.messages:
|
|
1879
|
+
for content in message.contents:
|
|
1880
|
+
if content.type not in TOOL_CALL_CONTENT_TYPES:
|
|
1881
|
+
continue
|
|
1882
|
+
call_id = content.call_id or ""
|
|
1883
|
+
name = _call_name(content)
|
|
1884
|
+
call_info[call_id] = (name, _format_arguments(content.arguments))
|
|
1885
|
+
call_order.append(call_id)
|
|
1886
|
+
lines.append(f"- call {name}({call_info[call_id][1]})")
|
|
1887
|
+
if message.role == "assistant":
|
|
1888
|
+
text = " ".join(
|
|
1889
|
+
content.text or "" for content in message.contents if content.type == "text" and content.text
|
|
1890
|
+
).strip()
|
|
1891
|
+
if text:
|
|
1892
|
+
lines.append(f"- agent said: {_truncate_head(text, _FALLBACK_ASSISTANT_MAX)}")
|
|
1893
|
+
for message in group.messages:
|
|
1894
|
+
for content in message.contents:
|
|
1895
|
+
if not content.type.endswith("_result"):
|
|
1896
|
+
continue
|
|
1897
|
+
call_id = content.call_id or ""
|
|
1898
|
+
name = call_info.get(call_id, ("tool", ""))[0]
|
|
1899
|
+
lines.append(f" result[{name}]: {_result_payload_text(content)}")
|
|
1900
|
+
if not lines:
|
|
1901
|
+
return None
|
|
1902
|
+
return _TimelineItem("\n".join(lines), tool_calls=max(1, len(call_order)))
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
def _call_name(content: Content) -> str:
|
|
1906
|
+
if content.type == "function_call":
|
|
1907
|
+
return content.name or "tool"
|
|
1908
|
+
if content.type in {"hosted_tool_call", "mcp_server_tool_call", "search_tool_call"}:
|
|
1909
|
+
return content.tool_name or "tool"
|
|
1910
|
+
return content.type.removesuffix("_tool_call") or "tool"
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def _format_arguments(arguments: object) -> str:
|
|
1914
|
+
if arguments is None:
|
|
1915
|
+
return ""
|
|
1916
|
+
if isinstance(arguments, Mapping):
|
|
1917
|
+
return ", ".join(
|
|
1918
|
+
f"{key}={json.dumps(value, ensure_ascii=False, default=str)}" for key, value in arguments.items()
|
|
1919
|
+
)
|
|
1920
|
+
return str(arguments)
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
def _result_payload_text(content: Content) -> str:
|
|
1924
|
+
if content.type in {"function_result", "hosted_tool_result", "search_tool_result"}:
|
|
1925
|
+
value = content.items or content.result
|
|
1926
|
+
elif content.type == "mcp_server_tool_result":
|
|
1927
|
+
value = content.output
|
|
1928
|
+
else:
|
|
1929
|
+
value = content.outputs
|
|
1930
|
+
# The compaction renderer: an image or other binary payload becomes a short
|
|
1931
|
+
# placeholder, never its base64 data.
|
|
1932
|
+
return _render_tool_result_value(value)
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
def _user_authored_text(message: Message) -> str:
|
|
1936
|
+
parts: list[str] = []
|
|
1937
|
+
for content in message.contents:
|
|
1938
|
+
if content.type != "text" or not content.text:
|
|
1939
|
+
continue
|
|
1940
|
+
text = content.text.strip()
|
|
1941
|
+
if text.startswith("<system-reminder>\n") and text.endswith("</system-reminder>"):
|
|
1942
|
+
continue
|
|
1943
|
+
parts.append(content.text)
|
|
1944
|
+
return " ".join(parts).strip()
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
def _bound_timeline(items: list[_TimelineItem], *, max_chars: int, newest_few: bool) -> str:
|
|
1948
|
+
ceiling = max(1, max_chars)
|
|
1949
|
+
retained = list(items)
|
|
1950
|
+
omitted_calls = 0
|
|
1951
|
+
if newest_few and len(retained) > _FALLBACK_NEWEST_LINES:
|
|
1952
|
+
omitted_calls += sum(item.tool_calls for item in retained[:-_FALLBACK_NEWEST_LINES])
|
|
1953
|
+
retained = retained[-_FALLBACK_NEWEST_LINES:]
|
|
1954
|
+
|
|
1955
|
+
def render() -> str:
|
|
1956
|
+
lines = [item.text for item in retained]
|
|
1957
|
+
if omitted_calls:
|
|
1958
|
+
lines.insert(0, f"… {omitted_calls} earlier calls omitted — indexed in the dropped-record manifest")
|
|
1959
|
+
return "\n".join(lines) if lines else "(nothing dropped)"
|
|
1960
|
+
|
|
1961
|
+
rendered = render()
|
|
1962
|
+
while len(rendered) > ceiling:
|
|
1963
|
+
tool_indices = [index for index, item in enumerate(retained) if item.tool_calls]
|
|
1964
|
+
if len(tool_indices) <= 1:
|
|
1965
|
+
break
|
|
1966
|
+
removed = retained.pop(tool_indices[0])
|
|
1967
|
+
omitted_calls += removed.tool_calls
|
|
1968
|
+
rendered = render()
|
|
1969
|
+
if len(rendered) <= ceiling:
|
|
1970
|
+
return rendered
|
|
1971
|
+
marker = f"{_FALLBACK_TRUNCATION_MARKER}\n"
|
|
1972
|
+
if omitted_calls:
|
|
1973
|
+
marker = f"… {omitted_calls} earlier calls omitted — indexed in the dropped-record manifest\n{marker}"
|
|
1974
|
+
if len(marker) >= ceiling:
|
|
1975
|
+
return marker[:ceiling]
|
|
1976
|
+
return marker + rendered[-(ceiling - len(marker)) :]
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
def _truncate_head(text: str, limit: int) -> str:
|
|
1980
|
+
if len(text) <= limit:
|
|
1981
|
+
return text
|
|
1982
|
+
if limit <= len(_FALLBACK_TRUNCATION_MARKER):
|
|
1983
|
+
return _FALLBACK_TRUNCATION_MARKER[:limit]
|
|
1984
|
+
keep = limit - len(_FALLBACK_TRUNCATION_MARKER)
|
|
1985
|
+
return text[:keep] + _FALLBACK_TRUNCATION_MARKER
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
def _middle_truncate_previous_note(text: str, limit: int) -> str:
|
|
1989
|
+
if len(text) <= limit:
|
|
1990
|
+
return text
|
|
1991
|
+
if limit <= len(_FALLBACK_PREV_NOTE_TRUNCATION_MARKER):
|
|
1992
|
+
return _FALLBACK_PREV_NOTE_TRUNCATION_MARKER[:limit]
|
|
1993
|
+
keep = limit - len(_FALLBACK_PREV_NOTE_TRUNCATION_MARKER)
|
|
1994
|
+
head = (keep + 1) // 2
|
|
1995
|
+
tail = keep - head
|
|
1996
|
+
return f"{text[:head]}{_FALLBACK_PREV_NOTE_TRUNCATION_MARKER}{text[len(text) - tail :]}"
|