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,1380 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""ResponseValidationMiddleware — retries on malformed agent responses.
|
|
4
|
+
|
|
5
|
+
Some LLM / inference backends (vLLM, custom routers, quantized model
|
|
6
|
+
weights) occasionally produce structurally invalid final responses:
|
|
7
|
+
empty content arrays, whitespace-only text, or text channels that leaked
|
|
8
|
+
tool-call markers like ``minimax:tool_call ... </minimax:tool_call>``.
|
|
9
|
+
These terminate the agent tool loop with a useless final message.
|
|
10
|
+
|
|
11
|
+
This :class:`ChatMiddleware` sits at the **innermost** position of the
|
|
12
|
+
chat middleware chain (closest to ``_inner_get_response``) so its
|
|
13
|
+
retries are invisible to the outer instrumentation — ``UsageTracking``,
|
|
14
|
+
``SystemReminder``, and the loop-level ``LoopRecorder`` all fire exactly
|
|
15
|
+
once per turn regardless of how many retries happen here.
|
|
16
|
+
|
|
17
|
+
Strategy
|
|
18
|
+
--------
|
|
19
|
+
* Run the chat call via ``call_next()``.
|
|
20
|
+
* For streaming: return a lazy validating proxy. It emits one raw,
|
|
21
|
+
content-free heartbeat for every provider update while draining the inner
|
|
22
|
+
:class:`ResponseStream`, then finalises and validates. Transport errors
|
|
23
|
+
propagate unchanged so the outer
|
|
24
|
+
:class:`~chrys.foundation.retry.StreamRetryLoop` handles them.
|
|
25
|
+
* Validate the finalised :class:`ChatResponse` with a
|
|
26
|
+
:class:`ResponseValidator`.
|
|
27
|
+
* On a valid response, the proxy replays the buffered model-content
|
|
28
|
+
updates. Its finalizer returns the already-finalised response verbatim
|
|
29
|
+
so the inner provider's ``result_hooks`` (telemetry, usage) do NOT fire
|
|
30
|
+
twice.
|
|
31
|
+
* On a terminal, non-retryable invalid response, raise
|
|
32
|
+
:class:`TerminalResponseValidationError` so the TurnBindings observer records a
|
|
33
|
+
normal turn error and persists an error marker for replay. Retryable
|
|
34
|
+
verdicts flagged ``terminal_on_giveup`` (reasoning-only responses with
|
|
35
|
+
no visible answer) take the same raise once retries are exhausted —
|
|
36
|
+
their scrubbed response would be blank, and returning it would read as
|
|
37
|
+
a successful empty answer instead of the failure it is.
|
|
38
|
+
* In service-storage mode failures are never replayed in place: retryable
|
|
39
|
+
verdicts raise :class:`RetryableResponseValidationError` to the whole-run
|
|
40
|
+
retry boundary, with the same budget and identical-reason short-circuit
|
|
41
|
+
applied across those outer attempts (give-up is always terminal there).
|
|
42
|
+
* On an invalid local response, warn, sleep with exponential-ish backoff,
|
|
43
|
+
and ``call_next()`` again — up to :data:`MAX_RETRIES` additional raw
|
|
44
|
+
requests inside the same logical wire call.
|
|
45
|
+
* On the **final** attempt we return the exhausted response — but with
|
|
46
|
+
``function_call`` contents stripped first. Without stripping, an
|
|
47
|
+
exhausted-but-still-malformed response carrying real ``function_call``
|
|
48
|
+
items would be executed by the tool loop, the loop would iterate, and
|
|
49
|
+
a model that's stuck in a bad state would re-trigger the full
|
|
50
|
+
validation retry cycle on every iteration (5 retries after 5
|
|
51
|
+
retries, indefinitely). Stripping forces ``_extract_function_calls``
|
|
52
|
+
to return ``[]`` so the tool loop exits cleanly with whatever (still-
|
|
53
|
+
bad) text we got.
|
|
54
|
+
* On **both** the valid and give-up paths we additionally drop
|
|
55
|
+
structurally-empty assistant outputs (``contents=[]`` or
|
|
56
|
+
whitespace-only-text messages, plus their corresponding updates).
|
|
57
|
+
``DefaultResponseValidator`` only inspects the *final* assistant
|
|
58
|
+
message, so a response shaped like ``[empty, valid]`` would otherwise
|
|
59
|
+
pass validation with a stray ``content: []`` leading message that
|
|
60
|
+
lands in persisted ``session.json`` next to the engine's turn marker.
|
|
61
|
+
The update-level pass is group-aware (mirroring
|
|
62
|
+
``_process_update``'s role / ``message_id`` boundary rules) so a
|
|
63
|
+
metadata-bearing terminal update sharing a non-empty group survives
|
|
64
|
+
and the rebuild keeps ``finish_reason`` / ``response_id``.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import asyncio
|
|
70
|
+
import inspect
|
|
71
|
+
import logging
|
|
72
|
+
import math
|
|
73
|
+
from collections.abc import Mapping
|
|
74
|
+
from dataclasses import dataclass
|
|
75
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
76
|
+
|
|
77
|
+
from chrys.foundation.errors import ContinuationVerdictError, ProviderResponseError
|
|
78
|
+
from chrys.foundation.hosted_tools import (
|
|
79
|
+
HeldHostedEvidence,
|
|
80
|
+
HostedRetrySafety,
|
|
81
|
+
HostedToolPhase,
|
|
82
|
+
HostedToolStatus,
|
|
83
|
+
normalize_hosted_tool_status,
|
|
84
|
+
)
|
|
85
|
+
from chrys.foundation.retry import RetryAttemptInfo
|
|
86
|
+
from chrys.foundation.trajectory.event_types import ValidationReason
|
|
87
|
+
from chrys.kernel import (
|
|
88
|
+
ChatResponse,
|
|
89
|
+
ChatResponseUpdate,
|
|
90
|
+
ContextOverflowSink,
|
|
91
|
+
Message,
|
|
92
|
+
ResponseStream,
|
|
93
|
+
in_internal_side_call,
|
|
94
|
+
normalize_stream_usage,
|
|
95
|
+
resolve_storage_mode_and_handles,
|
|
96
|
+
)
|
|
97
|
+
from chrys.kernel.exchanges import (
|
|
98
|
+
TOOL_CALL_CONTENT_TYPES,
|
|
99
|
+
TOOL_RESULT_CONTENT_TYPES,
|
|
100
|
+
EmptyIdPolicy,
|
|
101
|
+
LiveAccessor,
|
|
102
|
+
NoneIdPolicy,
|
|
103
|
+
PairingKey,
|
|
104
|
+
PairingPolicy,
|
|
105
|
+
iter_exchanges,
|
|
106
|
+
namespaced_pairing_key,
|
|
107
|
+
pair_results,
|
|
108
|
+
)
|
|
109
|
+
from chrys.kernel.middleware import ChatMiddleware
|
|
110
|
+
from chrys.service.agent_middleware.events.hosted_tools import (
|
|
111
|
+
HostedToolArgsOp,
|
|
112
|
+
HostedToolProgressOp,
|
|
113
|
+
HostedToolResultOp,
|
|
114
|
+
HostedToolStartOp,
|
|
115
|
+
HostedToolStatusOp,
|
|
116
|
+
ResponsePresentationPlan,
|
|
117
|
+
adapt_hosted_tool,
|
|
118
|
+
)
|
|
119
|
+
from chrys.service.agent_middleware.validators import DefaultResponseValidator, ValidationResult
|
|
120
|
+
from chrys.service.trajectory.validation import ValidationTrace
|
|
121
|
+
|
|
122
|
+
if TYPE_CHECKING:
|
|
123
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
|
|
124
|
+
|
|
125
|
+
from chrys.kernel.middleware import ChatContext
|
|
126
|
+
from chrys.service.agent_middleware.validators import ResponseValidator
|
|
127
|
+
|
|
128
|
+
logger = logging.getLogger(__name__)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Maximum number of retries on top of the initial attempt. Three retries
|
|
132
|
+
# means up to four total LLM calls per turn in the worst case. On the
|
|
133
|
+
# final (fourth) attempt we return whatever we got, valid or not.
|
|
134
|
+
MAX_RETRIES = 3
|
|
135
|
+
|
|
136
|
+
# Backoff schedule (seconds) — indexed by attempt number. A missing
|
|
137
|
+
# slot reuses the last value. Deliberately short since the dominant
|
|
138
|
+
# cost is the LLM call itself, not the sleep.
|
|
139
|
+
DEFAULT_BACKOFF_SCHEDULE: tuple[float, ...] = (1.0, 2.0, 4.0, 8.0, 16.0)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ResponseValidationError(Exception):
|
|
143
|
+
"""Base for validation failures whose rejected attempt consumed billed tokens.
|
|
144
|
+
|
|
145
|
+
``usage_details`` carries the failed attempt's provider usage so the
|
|
146
|
+
usage-tracking middleware can bill it before the exception continues to
|
|
147
|
+
its owner (the pass observer error path or the whole-run retry boundary).
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, reason: str, *, usage_details: dict[str, Any] | None = None) -> None:
|
|
151
|
+
super().__init__(reason)
|
|
152
|
+
self.usage_details = usage_details
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TerminalResponseValidationError(ResponseValidationError, RuntimeError):
|
|
156
|
+
"""Raised when response validation fails in a non-retryable way."""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class ValidationRetryExemption:
|
|
161
|
+
"""Independent stored-mode validation retry budget metadata."""
|
|
162
|
+
|
|
163
|
+
attempt: int
|
|
164
|
+
max_attempts: int
|
|
165
|
+
delay_seconds: int
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class RetryableResponseValidationError(ResponseValidationError, ConnectionError, ContinuationVerdictError):
|
|
169
|
+
"""A stored response failed validation and must retry at the outer boundary.
|
|
170
|
+
|
|
171
|
+
``hosted_commits`` names the provider-hosted tool calls (hosted MCP,
|
|
172
|
+
hosted shell) the rejected response already executed server-side. Those
|
|
173
|
+
exchanges never reach the loop recorder — the invalid response is
|
|
174
|
+
swallowed here before the kernel loop sees it — so this field is the
|
|
175
|
+
only carrier by which the whole-run retry owner can honour them as
|
|
176
|
+
commit points instead of re-creating the request and running the side
|
|
177
|
+
effects a second time.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
# Read by the kernel's retry wrappers through the classifier: this failure
|
|
181
|
+
# judged a TERMINAL response, so a live continuation token now refers to a
|
|
182
|
+
# completed (immutable) response — a retry must issue a fresh request,
|
|
183
|
+
# never re-poll the same invalid completion.
|
|
184
|
+
invalidates_continuation_token = True
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
reason: str,
|
|
189
|
+
*,
|
|
190
|
+
exemption: ValidationRetryExemption,
|
|
191
|
+
usage_details: dict[str, Any] | None = None,
|
|
192
|
+
hosted_commits: tuple[str, ...] = (),
|
|
193
|
+
) -> None:
|
|
194
|
+
super().__init__(reason, usage_details=usage_details)
|
|
195
|
+
self.exemption = exemption
|
|
196
|
+
self.hosted_commits = hosted_commits
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def hosted_commits_from_error(exc: BaseException) -> tuple[str, ...]:
|
|
200
|
+
"""Hosted tool calls the failed attempt behind *exc* already executed."""
|
|
201
|
+
if isinstance(exc, RetryableResponseValidationError):
|
|
202
|
+
return exc.hosted_commits
|
|
203
|
+
return ()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class ResponseValidationObservationHook(Protocol):
|
|
207
|
+
"""Optional observer for raw content and validation-attempt boundaries."""
|
|
208
|
+
|
|
209
|
+
async def begin_response(self, *, response_index: int | None = None, batch_id: int | None = None) -> None: ...
|
|
210
|
+
|
|
211
|
+
async def observe_contents(self, contents: Sequence[Any], *, is_final: bool = False) -> None: ...
|
|
212
|
+
|
|
213
|
+
async def attempt_started(self, *, continuation: bool = False) -> None: ...
|
|
214
|
+
|
|
215
|
+
async def attempt_rejected(self, reason: str = "") -> None: ...
|
|
216
|
+
|
|
217
|
+
async def attempt_accepted(self, messages: Sequence[Message]) -> None: ...
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_LIVE_ACCESSOR = LiveAccessor()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _hosted_commit_labels(response: ChatResponse) -> tuple[str, ...]:
|
|
224
|
+
"""Deduplicated display names of side-effectful hosted calls in *response*."""
|
|
225
|
+
labels = _hosted_commit_labels_from_plan(ResponsePresentationPlan.from_messages(response.messages or []))
|
|
226
|
+
if labels:
|
|
227
|
+
return labels
|
|
228
|
+
return _hosted_commit_labels_from_contents(
|
|
229
|
+
content for message in response.messages or [] for content in message.contents
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _hosted_commit_labels_from_contents(
|
|
234
|
+
candidates: Iterable[Any],
|
|
235
|
+
call_labels: Mapping[str, str] | None = None,
|
|
236
|
+
) -> tuple[str, ...]:
|
|
237
|
+
"""Deduplicated display names of side-effectful hosted calls in *candidates*."""
|
|
238
|
+
labels: list[str] = []
|
|
239
|
+
for label, _key in _hosted_commits_from_contents(candidates, call_labels):
|
|
240
|
+
if label not in labels:
|
|
241
|
+
labels.append(label)
|
|
242
|
+
return tuple(labels)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _hosted_commits_from_contents(
|
|
246
|
+
candidates: Iterable[Any],
|
|
247
|
+
call_labels: Mapping[str, str] | None = None,
|
|
248
|
+
) -> list[tuple[str, PairingKey | None]]:
|
|
249
|
+
"""Display name and pairing key of each side-effectful hosted call content in *candidates*.
|
|
250
|
+
|
|
251
|
+
Result contents carry no tool name of their own, so each is labelled
|
|
252
|
+
via its paired call when one is present in the same batch.
|
|
253
|
+
"""
|
|
254
|
+
contents = [content for content in candidates if content.provider_hosted]
|
|
255
|
+
names_by_call = dict(call_labels or {})
|
|
256
|
+
names_by_call.update(
|
|
257
|
+
{
|
|
258
|
+
content.call_id: content.tool_name
|
|
259
|
+
for content in contents
|
|
260
|
+
if content.type in TOOL_CALL_CONTENT_TYPES and content.call_id and content.tool_name
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
commits: list[tuple[str, PairingKey | None]] = []
|
|
264
|
+
for content in contents:
|
|
265
|
+
if content.type in TOOL_CALL_CONTENT_TYPES:
|
|
266
|
+
view = adapt_hosted_tool(content)
|
|
267
|
+
elif content.type in TOOL_RESULT_CONTENT_TYPES:
|
|
268
|
+
view = adapt_hosted_tool(None, content)
|
|
269
|
+
else:
|
|
270
|
+
continue
|
|
271
|
+
if not _view_is_hosted_commit(view):
|
|
272
|
+
continue
|
|
273
|
+
label = view.tool_name
|
|
274
|
+
if content.type in TOOL_RESULT_CONTENT_TYPES:
|
|
275
|
+
label = names_by_call.get(content.call_id) or content.tool_name or content.type
|
|
276
|
+
commits.append((label, namespaced_pairing_key(content.type, _LIVE_ACCESSOR.raw_id(content))))
|
|
277
|
+
return commits
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Recoverable hosted work: a call counts once a result with its id answers it; an id-less occurrence
|
|
281
|
+
# answers nothing, so its call is never counted as kept.
|
|
282
|
+
_KEPT_HOSTED_PAIRING_POLICY = PairingPolicy(
|
|
283
|
+
call_types=TOOL_CALL_CONTENT_TYPES,
|
|
284
|
+
include_informational_calls=True,
|
|
285
|
+
result_types=TOOL_RESULT_CONTENT_TYPES,
|
|
286
|
+
none_id=NoneIdPolicy.IGNORE,
|
|
287
|
+
empty_id=EmptyIdPolicy.IGNORE,
|
|
288
|
+
malformed_id="treat_as_none",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _landed_exchange_answered_keys(messages: Sequence[Message], landed: Sequence[Message]) -> set[PairingKey]:
|
|
293
|
+
"""Pairing keys answered by the exchange of *messages* whose response holds *landed*'s Content objects.
|
|
294
|
+
|
|
295
|
+
Identity, not ids, finds the exchange: an earlier exchange reusing the same call id is other work.
|
|
296
|
+
"""
|
|
297
|
+
landed_contents = {id(content) for message in landed for content in message.contents}
|
|
298
|
+
for exchange in reversed(list(iter_exchanges(messages, _LIVE_ACCESSOR))):
|
|
299
|
+
if any(
|
|
300
|
+
id(content) in landed_contents
|
|
301
|
+
for index in exchange.response_indices
|
|
302
|
+
for content in messages[index].contents
|
|
303
|
+
):
|
|
304
|
+
return pair_results(messages, exchange, _LIVE_ACCESSOR, _KEPT_HOSTED_PAIRING_POLICY).answered_keys
|
|
305
|
+
return set()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _view_is_hosted_commit(view: Any) -> bool:
|
|
309
|
+
"""Return whether one adapted view proves retry-blocking execution."""
|
|
310
|
+
if view.retry_safety not in {HostedRetrySafety.SIDE_EFFECTFUL, HostedRetrySafety.UNKNOWN}:
|
|
311
|
+
return False
|
|
312
|
+
status = normalize_hosted_tool_status(view.status)
|
|
313
|
+
return view.phase in {
|
|
314
|
+
HostedToolPhase.START,
|
|
315
|
+
HostedToolPhase.DELTA,
|
|
316
|
+
HostedToolPhase.SNAPSHOT,
|
|
317
|
+
HostedToolPhase.TERMINAL,
|
|
318
|
+
} or status in {
|
|
319
|
+
HostedToolStatus.RUNNING,
|
|
320
|
+
HostedToolStatus.COMPLETED,
|
|
321
|
+
HostedToolStatus.FAILED,
|
|
322
|
+
HostedToolStatus.INTERRUPTED,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _hosted_commit_labels_from_plan(plan: ResponsePresentationPlan) -> tuple[str, ...]:
|
|
327
|
+
"""Return retry-blocking labels from exchange-paired presentation views."""
|
|
328
|
+
labels: list[str] = []
|
|
329
|
+
for operation in plan.operations:
|
|
330
|
+
if not isinstance(
|
|
331
|
+
operation,
|
|
332
|
+
HostedToolStartOp | HostedToolArgsOp | HostedToolProgressOp | HostedToolStatusOp | HostedToolResultOp,
|
|
333
|
+
):
|
|
334
|
+
continue
|
|
335
|
+
view = operation.view
|
|
336
|
+
if not _view_is_hosted_commit(view):
|
|
337
|
+
continue
|
|
338
|
+
label = view.tool_name or view.family or "hosted_tool"
|
|
339
|
+
if label not in labels:
|
|
340
|
+
labels.append(label)
|
|
341
|
+
return tuple(labels)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _note_output_truncated(context: ChatContext, verdict: ValidationResult, response: ChatResponse) -> None:
|
|
345
|
+
"""Tell the compaction strategy when the input left the reply no room.
|
|
346
|
+
|
|
347
|
+
A length stop with nothing generated stays terminal; the next request
|
|
348
|
+
compacts first. A reasoning-only length stop is not this: the model did
|
|
349
|
+
generate, so the window was not full. Neither is an empty reply whose
|
|
350
|
+
usage counts reasoning tokens the API did not return.
|
|
351
|
+
"""
|
|
352
|
+
if verdict.code != ValidationReason.OUTPUT_TRUNCATED:
|
|
353
|
+
return
|
|
354
|
+
if (response.usage_details or {}).get("reasoning_output_token_count"):
|
|
355
|
+
return
|
|
356
|
+
strategy = context.kwargs.get("compaction_strategy")
|
|
357
|
+
if isinstance(strategy, ContextOverflowSink):
|
|
358
|
+
strategy.note_context_overflow()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class ResponseValidationMiddleware(ChatMiddleware):
|
|
362
|
+
"""Retry the LLM call on structurally invalid final responses.
|
|
363
|
+
|
|
364
|
+
Retries assume the failure is *transient* — random sampling produced
|
|
365
|
+
a one-off bad output that a re-roll will fix. When the producer is
|
|
366
|
+
*deterministically* stuck (chat-template / KV-cache mismatch, fine-
|
|
367
|
+
tuning quirk, prefix-cache hit at temperature 0), every retry sees
|
|
368
|
+
the same input and returns the same bad output, so retrying just
|
|
369
|
+
burns latency before the inevitable give-up. To avoid that, this
|
|
370
|
+
middleware fails fast: when attempt N+1's
|
|
371
|
+
:attr:`ValidationResult.reason` exactly matches attempt N's, the
|
|
372
|
+
loop short-circuits to the give-up path immediately instead of
|
|
373
|
+
completing the remaining retries.
|
|
374
|
+
|
|
375
|
+
In service-storage mode a failed attempt is never replayed in place —
|
|
376
|
+
it raises to the whole-run retry boundary instead — so the retry
|
|
377
|
+
budget and the identical-reason short-circuit are carried on the
|
|
378
|
+
instance across those outer attempts; once either trips, the failure
|
|
379
|
+
is raised terminally.
|
|
380
|
+
|
|
381
|
+
Class attributes :attr:`_MAX_RETRIES` and :attr:`_BACKOFF_SCHEDULE`
|
|
382
|
+
hold defaults that can be monkeypatched in tests (mirrors the pattern
|
|
383
|
+
used by :class:`~chrys.orchestration.invoker.attempts.AttemptRunner`). Constructor arguments
|
|
384
|
+
override these defaults per-instance.
|
|
385
|
+
|
|
386
|
+
Parameters
|
|
387
|
+
----------
|
|
388
|
+
validator:
|
|
389
|
+
Component that inspects each :class:`ChatResponse` and returns
|
|
390
|
+
:class:`~chrys.service.agent_middleware.validators.ValidationResult`.
|
|
391
|
+
Defaults to :class:`DefaultResponseValidator` with the three
|
|
392
|
+
built-in rules (empty contents, whitespace text, leaked tool
|
|
393
|
+
calls).
|
|
394
|
+
max_retries:
|
|
395
|
+
Maximum number of retries on top of the initial attempt.
|
|
396
|
+
``None`` → use :attr:`_MAX_RETRIES` (default :data:`MAX_RETRIES`).
|
|
397
|
+
backoff_schedule:
|
|
398
|
+
Sleep duration (seconds) between attempts, indexed by attempt
|
|
399
|
+
number. Past the last entry the last value is reused. ``None``
|
|
400
|
+
→ use :attr:`_BACKOFF_SCHEDULE` (default
|
|
401
|
+
:data:`DEFAULT_BACKOFF_SCHEDULE`).
|
|
402
|
+
publish_retry:
|
|
403
|
+
Optional observer fired before each validation replay sleep. Production
|
|
404
|
+
orchestration leaves this unset: logical wire-retry events have one
|
|
405
|
+
publisher, the kernel policy's ``on_retry`` callback.
|
|
406
|
+
observation_hook:
|
|
407
|
+
Optional raw-content and attempt-lifecycle observer. Callback failures
|
|
408
|
+
are isolated from validation and hosted commit-point tracking.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
# Class-level defaults so tests can monkeypatch via
|
|
412
|
+
# ``monkeypatch.setattr(ResponseValidationMiddleware, "_BACKOFF_SCHEDULE", (0,))``
|
|
413
|
+
# without having to plumb constructor kwargs through every test fixture.
|
|
414
|
+
_MAX_RETRIES: int = MAX_RETRIES
|
|
415
|
+
_BACKOFF_SCHEDULE: tuple[float, ...] = DEFAULT_BACKOFF_SCHEDULE
|
|
416
|
+
|
|
417
|
+
def __init__(
|
|
418
|
+
self,
|
|
419
|
+
*,
|
|
420
|
+
validator: ResponseValidator | None = None,
|
|
421
|
+
max_retries: int | None = None,
|
|
422
|
+
backoff_schedule: Sequence[float] | None = None,
|
|
423
|
+
publish_retry: Callable[[RetryAttemptInfo], Awaitable[None]] | None = None,
|
|
424
|
+
observation_hook: ResponseValidationObservationHook | None = None,
|
|
425
|
+
) -> None:
|
|
426
|
+
self._validator: ResponseValidator = validator or DefaultResponseValidator()
|
|
427
|
+
self._max_retries = max(0, int(max_retries if max_retries is not None else type(self)._MAX_RETRIES))
|
|
428
|
+
schedule = backoff_schedule if backoff_schedule is not None else type(self)._BACKOFF_SCHEDULE
|
|
429
|
+
self._backoff = tuple(schedule) or (0.0,)
|
|
430
|
+
self._publish_retry = publish_retry
|
|
431
|
+
self._observation_hook = observation_hook
|
|
432
|
+
# Service-storage failures retry at the whole-run boundary, so each
|
|
433
|
+
# outer attempt re-enters :meth:`process` with fresh loop state. The
|
|
434
|
+
# instance outlives those attempts; carrying the count and last reason
|
|
435
|
+
# here keeps the retry budget and the deterministic-failure
|
|
436
|
+
# short-circuit effective in that mode.
|
|
437
|
+
self._service_retry_count = 0
|
|
438
|
+
self._service_retry_reason: str | None = None
|
|
439
|
+
# Provider-hosted tool calls observed executing (hosted MCP, hosted
|
|
440
|
+
# shell) at two scopes matched to what each retry owner discards:
|
|
441
|
+
# the run scope backs the whole-run gates (a whole-run retry restores
|
|
442
|
+
# pre-run history, so ANY hosted work this run would re-execute); the
|
|
443
|
+
# wire-attempt scope backs the kernel's per-wire replay veto (an
|
|
444
|
+
# in-place replay only re-sends the current request, so only hosted
|
|
445
|
+
# work from the aborted attempt itself is at risk — an attempt being
|
|
446
|
+
# the request that started a response plus the polls that resume it).
|
|
447
|
+
# The attempt's commits also keep their pairing keys, so a pass
|
|
448
|
+
# resumed from repaired history can tell which ones that history
|
|
449
|
+
# already answers.
|
|
450
|
+
self._hosted_commits_seen: list[str] = []
|
|
451
|
+
self._hosted_commits_in_flight: list[str] = []
|
|
452
|
+
self._hosted_commit_keys_in_flight: list[tuple[str, PairingKey | None]] = []
|
|
453
|
+
self._hosted_call_labels_in_flight: dict[str, str] = {}
|
|
454
|
+
|
|
455
|
+
def set_observation_hook(self, hook: ResponseValidationObservationHook | None) -> None:
|
|
456
|
+
"""Attach the run-scoped hosted-presentation observer."""
|
|
457
|
+
self._observation_hook = hook
|
|
458
|
+
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
# Hosted-commit observation (retry-owner probes)
|
|
461
|
+
# ------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
def hosted_commits_observed(self) -> tuple[str, ...]:
|
|
464
|
+
"""Hosted tool calls executed since the retry baseline (run/pass start)."""
|
|
465
|
+
return tuple(self._hosted_commits_seen)
|
|
466
|
+
|
|
467
|
+
def hosted_commits_in_flight(self) -> tuple[str, ...]:
|
|
468
|
+
"""Hosted tool calls executed within the current wire attempt, polls that resumed it included."""
|
|
469
|
+
return tuple(self._hosted_commits_in_flight)
|
|
470
|
+
|
|
471
|
+
def hosted_commits_in_flight_unkept(
|
|
472
|
+
self, kept: Sequence[Message], landed: Sequence[Message] | None
|
|
473
|
+
) -> tuple[str, ...]:
|
|
474
|
+
"""Hosted tool calls of the current wire attempt that *kept* does not answer.
|
|
475
|
+
|
|
476
|
+
*kept* is the history a resumed pass sends; *landed* is the attempt's response as the tool loop
|
|
477
|
+
landed it, ``None`` while the request is still in flight. Only the exchange holding that response
|
|
478
|
+
counts: its answered hosted calls show the model their results instead of running again. A call
|
|
479
|
+
without a pairing id never counts as kept.
|
|
480
|
+
"""
|
|
481
|
+
answered = set() if landed is None else _landed_exchange_answered_keys(kept, landed)
|
|
482
|
+
labels: list[str] = []
|
|
483
|
+
for label, key in self._hosted_commit_keys_in_flight:
|
|
484
|
+
if (key is None or key not in answered) and label not in labels:
|
|
485
|
+
labels.append(label)
|
|
486
|
+
return tuple(labels)
|
|
487
|
+
|
|
488
|
+
def reset_hosted_commit_observations(self) -> None:
|
|
489
|
+
"""Forget observed hosted work — fired when the retry baseline resets."""
|
|
490
|
+
self._hosted_commits_seen.clear()
|
|
491
|
+
self._hosted_commits_in_flight.clear()
|
|
492
|
+
self._hosted_commit_keys_in_flight.clear()
|
|
493
|
+
self._hosted_call_labels_in_flight.clear()
|
|
494
|
+
|
|
495
|
+
def begin_pass_hosted_baseline(self, *, resumes_background_response: bool) -> None:
|
|
496
|
+
"""Start a pass's hosted-work baseline.
|
|
497
|
+
|
|
498
|
+
A pass that polls a background response an earlier pass left running keeps the observations: that
|
|
499
|
+
response's hosted calls may already have run, and if the poll ends without the response, a fresh
|
|
500
|
+
request would run them again.
|
|
501
|
+
"""
|
|
502
|
+
if not resumes_background_response:
|
|
503
|
+
self.reset_hosted_commit_observations()
|
|
504
|
+
|
|
505
|
+
def _begin_wire_attempt(self, *, continuation: bool) -> None:
|
|
506
|
+
# A poll resumes the response an earlier attempt started: the hosted
|
|
507
|
+
# work that response ran stays at risk until it ends, since a fresh
|
|
508
|
+
# request would run it again.
|
|
509
|
+
if continuation:
|
|
510
|
+
return
|
|
511
|
+
self._hosted_commits_in_flight.clear()
|
|
512
|
+
self._hosted_commit_keys_in_flight.clear()
|
|
513
|
+
self._hosted_call_labels_in_flight.clear()
|
|
514
|
+
|
|
515
|
+
def _observe_hosted_contents(self, contents: Iterable[Any]) -> None:
|
|
516
|
+
observed = list(contents)
|
|
517
|
+
self._hosted_call_labels_in_flight.update(
|
|
518
|
+
{
|
|
519
|
+
content.call_id: content.tool_name
|
|
520
|
+
for content in observed
|
|
521
|
+
if content.provider_hosted
|
|
522
|
+
and content.type in TOOL_CALL_CONTENT_TYPES
|
|
523
|
+
and content.call_id
|
|
524
|
+
and content.tool_name
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
for commit in _hosted_commits_from_contents(observed, self._hosted_call_labels_in_flight):
|
|
528
|
+
label = commit[0]
|
|
529
|
+
if label not in self._hosted_commits_seen:
|
|
530
|
+
self._hosted_commits_seen.append(label)
|
|
531
|
+
if label not in self._hosted_commits_in_flight:
|
|
532
|
+
self._hosted_commits_in_flight.append(label)
|
|
533
|
+
if commit not in self._hosted_commit_keys_in_flight:
|
|
534
|
+
self._hosted_commit_keys_in_flight.append(commit)
|
|
535
|
+
|
|
536
|
+
async def _observe_failed_response(self, error: ProviderResponseError) -> None:
|
|
537
|
+
"""Count the hosted work a failed response showed but never yielded.
|
|
538
|
+
|
|
539
|
+
The adapter raised before the response landed, so this is the only
|
|
540
|
+
place the retry gates learn that the work already ran.
|
|
541
|
+
"""
|
|
542
|
+
if contents := list(error.observed_contents):
|
|
543
|
+
self._observe_hosted_contents(contents)
|
|
544
|
+
await self._notify_contents_observed(contents, is_final=True)
|
|
545
|
+
|
|
546
|
+
async def _notify_contents_observed(self, contents: Sequence[Any], *, is_final: bool) -> None:
|
|
547
|
+
if self._observation_hook is None or in_internal_side_call():
|
|
548
|
+
return
|
|
549
|
+
try:
|
|
550
|
+
await self._observation_hook.observe_contents(contents, is_final=is_final)
|
|
551
|
+
except Exception:
|
|
552
|
+
logger.exception("response validation observation hook raised while observing contents")
|
|
553
|
+
|
|
554
|
+
async def _notify_response_started(self, *, continuation: bool) -> None:
|
|
555
|
+
if self._observation_hook is None or in_internal_side_call() or continuation:
|
|
556
|
+
return
|
|
557
|
+
try:
|
|
558
|
+
await self._observation_hook.begin_response()
|
|
559
|
+
except Exception:
|
|
560
|
+
logger.exception("response validation observation hook raised at response start")
|
|
561
|
+
|
|
562
|
+
async def _notify_attempt_started(self, *, continuation: bool = False) -> None:
|
|
563
|
+
if self._observation_hook is None or in_internal_side_call():
|
|
564
|
+
return
|
|
565
|
+
try:
|
|
566
|
+
await self._observation_hook.attempt_started(continuation=continuation)
|
|
567
|
+
except Exception:
|
|
568
|
+
logger.exception("response validation observation hook raised at attempt start")
|
|
569
|
+
|
|
570
|
+
async def _notify_attempt_rejected(self, reason: str) -> None:
|
|
571
|
+
if self._observation_hook is None or in_internal_side_call():
|
|
572
|
+
return
|
|
573
|
+
try:
|
|
574
|
+
await self._observation_hook.attempt_rejected(reason)
|
|
575
|
+
except Exception:
|
|
576
|
+
logger.exception("response validation observation hook raised at attempt rejection")
|
|
577
|
+
|
|
578
|
+
async def _notify_attempt_accepted(self, messages: Sequence[Message]) -> None:
|
|
579
|
+
if self._observation_hook is None or in_internal_side_call():
|
|
580
|
+
return
|
|
581
|
+
try:
|
|
582
|
+
await self._observation_hook.attempt_accepted(messages)
|
|
583
|
+
except Exception:
|
|
584
|
+
logger.exception("response validation observation hook raised at attempt acceptance")
|
|
585
|
+
|
|
586
|
+
# ------------------------------------------------------------------
|
|
587
|
+
# ChatMiddleware interface
|
|
588
|
+
# ------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
async def process(
|
|
591
|
+
self,
|
|
592
|
+
context: ChatContext,
|
|
593
|
+
call_next: Callable[[], Awaitable[None]],
|
|
594
|
+
) -> None:
|
|
595
|
+
try:
|
|
596
|
+
stores_by_default = bool(context.client.STORES_BY_DEFAULT)
|
|
597
|
+
except AttributeError:
|
|
598
|
+
stores_by_default = False
|
|
599
|
+
try:
|
|
600
|
+
force_stateless = bool(context.client.FORCES_STATELESS)
|
|
601
|
+
except AttributeError:
|
|
602
|
+
force_stateless = False
|
|
603
|
+
nested_client_kwargs = context.kwargs.get("client_kwargs")
|
|
604
|
+
storage_kwargs = nested_client_kwargs if isinstance(nested_client_kwargs, Mapping) else context.kwargs
|
|
605
|
+
service_side = resolve_storage_mode_and_handles(
|
|
606
|
+
context.options,
|
|
607
|
+
stores_by_default=stores_by_default,
|
|
608
|
+
client_kwargs=storage_kwargs,
|
|
609
|
+
force_stateless=force_stateless,
|
|
610
|
+
).service_side
|
|
611
|
+
continuation = isinstance(context.options, Mapping) and context.options.get("continuation_token") is not None
|
|
612
|
+
await self._notify_response_started(continuation=continuation)
|
|
613
|
+
validation = ValidationTrace.open(context.kwargs)
|
|
614
|
+
if context.stream:
|
|
615
|
+
# Return a lazy validating proxy immediately so raw, content-free provider
|
|
616
|
+
# heartbeats can reach the outer stream-idle watchdog during the inner drain.
|
|
617
|
+
# All model content remains buffered until its finalized response validates.
|
|
618
|
+
context.result = self._validated_response_stream(
|
|
619
|
+
context, call_next, service_side=service_side, validation=validation
|
|
620
|
+
)
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
# Total attempts = 1 initial + max_retries retries. The loop
|
|
624
|
+
# variable 0..max_retries tracks the retry count; attempt=0 is
|
|
625
|
+
# the initial call.
|
|
626
|
+
#
|
|
627
|
+
# ``previous_reason`` carries the prior attempt's
|
|
628
|
+
# :attr:`ValidationResult.reason` so :meth:`_should_give_up` can
|
|
629
|
+
# detect a deterministic-stuck producer (same failure mode twice
|
|
630
|
+
# in a row) and short-circuit to give-up instead of burning the
|
|
631
|
+
# remaining retries on what will almost certainly be the same
|
|
632
|
+
# bad output.
|
|
633
|
+
previous_reason: str | None = None
|
|
634
|
+
for attempt in range(self._max_retries + 1):
|
|
635
|
+
continuation = (
|
|
636
|
+
isinstance(context.options, Mapping) and context.options.get("continuation_token") is not None
|
|
637
|
+
)
|
|
638
|
+
self._begin_wire_attempt(continuation=continuation)
|
|
639
|
+
await self._notify_attempt_started(continuation=continuation)
|
|
640
|
+
try:
|
|
641
|
+
await call_next()
|
|
642
|
+
except ProviderResponseError as error:
|
|
643
|
+
await self._observe_failed_response(error)
|
|
644
|
+
raise
|
|
645
|
+
result = context.result
|
|
646
|
+
if not isinstance(result, ChatResponse):
|
|
647
|
+
return
|
|
648
|
+
observed_contents = [content for message in result.messages or [] for content in message.contents]
|
|
649
|
+
self._observe_hosted_contents(observed_contents)
|
|
650
|
+
await self._notify_contents_observed(observed_contents, is_final=True)
|
|
651
|
+
if result.continuation_token is not None:
|
|
652
|
+
await self._notify_attempt_accepted(result.messages or [])
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
verdict = self._validator.validate(result)
|
|
656
|
+
_note_output_truncated(context, verdict, result)
|
|
657
|
+
hosted_commits = () if verdict.ok else _hosted_commit_labels(result)
|
|
658
|
+
give_up = not verdict.ok and self._gives_up(
|
|
659
|
+
verdict,
|
|
660
|
+
attempt=attempt,
|
|
661
|
+
previous_reason=previous_reason,
|
|
662
|
+
hosted_committed=bool(hosted_commits),
|
|
663
|
+
service_side=service_side,
|
|
664
|
+
)
|
|
665
|
+
if validation is not None:
|
|
666
|
+
await validation.finished(
|
|
667
|
+
accepted=verdict.ok,
|
|
668
|
+
reason_code=None if verdict.ok else verdict.code,
|
|
669
|
+
retryable=None if verdict.ok else verdict.retryable,
|
|
670
|
+
gave_up=give_up,
|
|
671
|
+
)
|
|
672
|
+
if verdict.ok:
|
|
673
|
+
self.reset_service_retry_state()
|
|
674
|
+
# Even on a valid response, drop structurally-empty
|
|
675
|
+
# assistant messages. The default validator only
|
|
676
|
+
# inspects the FINAL assistant message, so a response
|
|
677
|
+
# shaped like ``[empty, valid]`` passes validation
|
|
678
|
+
# while still carrying a stray ``content: []`` message
|
|
679
|
+
# that would otherwise land in persisted history.
|
|
680
|
+
# Streaming providers/layers can also create empty
|
|
681
|
+
# assistant placeholders at message_id boundaries that
|
|
682
|
+
# never get populated — same fix.
|
|
683
|
+
_drop_empty_assistant_outputs(result, [])
|
|
684
|
+
await self._notify_attempt_accepted(result.messages or [])
|
|
685
|
+
return
|
|
686
|
+
if service_side:
|
|
687
|
+
await self._notify_attempt_rejected(verdict.reason)
|
|
688
|
+
self._service_side_failure(verdict.reason, result, code=verdict.code, give_up=give_up)
|
|
689
|
+
if hosted_commits:
|
|
690
|
+
# The rejected response already executed provider-hosted tool
|
|
691
|
+
# calls server-side; replaying the request in place would run
|
|
692
|
+
# those side effects a second time. Give up now — the scrubbed
|
|
693
|
+
# response keeps the hosted transcript in history.
|
|
694
|
+
logger.warning(
|
|
695
|
+
"Response validation would retry, but provider-hosted tool call(s) "
|
|
696
|
+
"already executed (%s) — not re-sending the request: %s",
|
|
697
|
+
", ".join(hosted_commits),
|
|
698
|
+
verdict.reason,
|
|
699
|
+
)
|
|
700
|
+
if give_up:
|
|
701
|
+
# A concluded cycle — terminal raise or scrubbed acceptance —
|
|
702
|
+
# must not leave stale service-side carry-over behind a later
|
|
703
|
+
# storage-mode flip.
|
|
704
|
+
self.reset_service_retry_state()
|
|
705
|
+
if not verdict.retryable or verdict.terminal_on_giveup:
|
|
706
|
+
await self._notify_attempt_rejected(verdict.reason)
|
|
707
|
+
self._raise_terminal(verdict.reason, result, code=verdict.code)
|
|
708
|
+
# Give-up path: scrub the exhausted response so neither
|
|
709
|
+
# the tool loop NOR persisted history is
|
|
710
|
+
# polluted by the malformed output:
|
|
711
|
+
#
|
|
712
|
+
# * function_call contents are stripped so
|
|
713
|
+
# ``_extract_function_calls`` returns ``[]`` and the
|
|
714
|
+
# loop exits cleanly (without this the model could
|
|
715
|
+
# re-trigger the full validation cycle every iteration).
|
|
716
|
+
# * empty / whitespace-only assistant messages are
|
|
717
|
+
# dropped (function_call stripping may itself empty a
|
|
718
|
+
# message — drop those too) so they don't land in
|
|
719
|
+
# ``session.json`` next to the engine's turn marker
|
|
720
|
+
# as a stray ``content: []`` message.
|
|
721
|
+
_strip_function_calls(result, [])
|
|
722
|
+
_drop_empty_assistant_outputs(result, [])
|
|
723
|
+
await self._notify_attempt_accepted(result.messages or [])
|
|
724
|
+
return
|
|
725
|
+
|
|
726
|
+
# Invalid + retries remaining: emit retry event + sleep.
|
|
727
|
+
previous_reason = verdict.reason
|
|
728
|
+
await self._notify_attempt_rejected(verdict.reason)
|
|
729
|
+
await self._note_retry(verdict.reason, attempt, validation=validation)
|
|
730
|
+
|
|
731
|
+
def _validated_response_stream(
|
|
732
|
+
self,
|
|
733
|
+
context: ChatContext,
|
|
734
|
+
call_next: Callable[[], Awaitable[None]],
|
|
735
|
+
*,
|
|
736
|
+
service_side: bool,
|
|
737
|
+
validation: ValidationTrace | None = None,
|
|
738
|
+
) -> ResponseStream[ChatResponseUpdate, ChatResponse]:
|
|
739
|
+
"""Validate lazily while emitting safe transport progress for every update."""
|
|
740
|
+
final_response: ChatResponse | None = None
|
|
741
|
+
# The inner stream of the attempt being read; None once that attempt
|
|
742
|
+
# was accepted or closed for a retry.
|
|
743
|
+
active_result: ResponseStream[ChatResponseUpdate, ChatResponse] | None = None
|
|
744
|
+
proxy: ResponseStream[ChatResponseUpdate, ChatResponse]
|
|
745
|
+
|
|
746
|
+
def _select_response(
|
|
747
|
+
response: ChatResponse,
|
|
748
|
+
result_hooks: Sequence[Callable[[ChatResponse], Any]],
|
|
749
|
+
) -> None:
|
|
750
|
+
nonlocal final_response
|
|
751
|
+
final_response = response
|
|
752
|
+
context.result = proxy
|
|
753
|
+
# The provider/inner hooks originally finalize before hooks installed
|
|
754
|
+
# by outer middleware. Outer hooks are already present on the lazy
|
|
755
|
+
# proxy by the time this attempt succeeds, so prepend the inner phase.
|
|
756
|
+
proxy._result_hooks[:0] = result_hooks
|
|
757
|
+
|
|
758
|
+
async def _updates() -> AsyncIterator[ChatResponseUpdate]:
|
|
759
|
+
nonlocal active_result
|
|
760
|
+
previous_reason: str | None = None
|
|
761
|
+
for attempt in range(self._max_retries + 1):
|
|
762
|
+
continuation = (
|
|
763
|
+
isinstance(context.options, Mapping) and context.options.get("continuation_token") is not None
|
|
764
|
+
)
|
|
765
|
+
self._begin_wire_attempt(continuation=continuation)
|
|
766
|
+
await self._notify_attempt_started(continuation=continuation)
|
|
767
|
+
updates: list[ChatResponseUpdate] = []
|
|
768
|
+
try:
|
|
769
|
+
await call_next()
|
|
770
|
+
result = context.result
|
|
771
|
+
if not isinstance(result, ResponseStream):
|
|
772
|
+
raise ValueError("Streaming response validation requires a ResponseStream result.")
|
|
773
|
+
active_result = result
|
|
774
|
+
|
|
775
|
+
await result
|
|
776
|
+
inner_hooks = _collect_and_clear_hooks(result)
|
|
777
|
+
async for update in result:
|
|
778
|
+
if isinstance(evidence := update.raw_representation, HeldHostedEvidence):
|
|
779
|
+
# Hosted work the adapter holds behind an
|
|
780
|
+
# unfinished call already ran: the retry gates
|
|
781
|
+
# count it now. The update that releases it in
|
|
782
|
+
# order presents it and keeps it for the replay.
|
|
783
|
+
self._observe_hosted_contents(evidence.contents)
|
|
784
|
+
yield _stream_heartbeat_for(update)
|
|
785
|
+
continue
|
|
786
|
+
# Record hosted executions the moment their updates
|
|
787
|
+
# land — a stall or transport drop after this point
|
|
788
|
+
# must reach the retry owners as commit evidence even
|
|
789
|
+
# though the buffered content never validated.
|
|
790
|
+
observed_contents = list(update.contents or [])
|
|
791
|
+
self._observe_hosted_contents(observed_contents)
|
|
792
|
+
await self._notify_contents_observed(observed_contents, is_final=False)
|
|
793
|
+
updates.append(update)
|
|
794
|
+
yield _stream_heartbeat_for(update)
|
|
795
|
+
except ProviderResponseError as error:
|
|
796
|
+
await self._observe_failed_response(error)
|
|
797
|
+
raise
|
|
798
|
+
final = await _preview_finalize(result, updates)
|
|
799
|
+
final_contents = [content for message in final.messages or [] for content in message.contents]
|
|
800
|
+
self._observe_hosted_contents(final_contents)
|
|
801
|
+
await self._notify_contents_observed(final_contents, is_final=True)
|
|
802
|
+
|
|
803
|
+
if final.continuation_token is not None:
|
|
804
|
+
_select_response(final, inner_hooks)
|
|
805
|
+
await self._notify_attempt_accepted(final.messages or [])
|
|
806
|
+
active_result = None
|
|
807
|
+
return
|
|
808
|
+
|
|
809
|
+
verdict = self._validator.validate(final)
|
|
810
|
+
_note_output_truncated(context, verdict, final)
|
|
811
|
+
hosted_commits = () if verdict.ok else _hosted_commit_labels(final)
|
|
812
|
+
give_up = not verdict.ok and self._gives_up(
|
|
813
|
+
verdict,
|
|
814
|
+
attempt=attempt,
|
|
815
|
+
previous_reason=previous_reason,
|
|
816
|
+
hosted_committed=bool(hosted_commits),
|
|
817
|
+
service_side=service_side,
|
|
818
|
+
)
|
|
819
|
+
if validation is not None:
|
|
820
|
+
await validation.finished(
|
|
821
|
+
accepted=verdict.ok,
|
|
822
|
+
reason_code=None if verdict.ok else verdict.code,
|
|
823
|
+
retryable=None if verdict.ok else verdict.retryable,
|
|
824
|
+
gave_up=give_up,
|
|
825
|
+
)
|
|
826
|
+
if verdict.ok:
|
|
827
|
+
self.reset_service_retry_state()
|
|
828
|
+
_drop_empty_assistant_outputs(final, updates)
|
|
829
|
+
_select_response(final, inner_hooks)
|
|
830
|
+
await self._notify_attempt_accepted(final.messages or [])
|
|
831
|
+
for update in updates:
|
|
832
|
+
if not _is_stream_heartbeat(update):
|
|
833
|
+
yield update
|
|
834
|
+
active_result = None
|
|
835
|
+
return
|
|
836
|
+
|
|
837
|
+
if service_side:
|
|
838
|
+
await self._notify_attempt_rejected(verdict.reason)
|
|
839
|
+
self._service_side_failure(
|
|
840
|
+
verdict.reason,
|
|
841
|
+
final,
|
|
842
|
+
code=verdict.code,
|
|
843
|
+
give_up=give_up,
|
|
844
|
+
stream_usage_chunks=_usage_chunks_from_updates(updates),
|
|
845
|
+
)
|
|
846
|
+
if hosted_commits:
|
|
847
|
+
# Same rule as the blocking path: hosted side effects
|
|
848
|
+
# in the rejected response forbid an in-place re-send.
|
|
849
|
+
logger.warning(
|
|
850
|
+
"Response validation would retry, but provider-hosted tool call(s) "
|
|
851
|
+
"already executed (%s) — not re-sending the request: %s",
|
|
852
|
+
", ".join(hosted_commits),
|
|
853
|
+
verdict.reason,
|
|
854
|
+
)
|
|
855
|
+
if give_up:
|
|
856
|
+
# A concluded cycle — terminal raise or scrubbed
|
|
857
|
+
# acceptance — must not leave stale service-side
|
|
858
|
+
# carry-over behind a later storage-mode flip.
|
|
859
|
+
self.reset_service_retry_state()
|
|
860
|
+
if not verdict.retryable or verdict.terminal_on_giveup:
|
|
861
|
+
await self._notify_attempt_rejected(verdict.reason)
|
|
862
|
+
self._raise_terminal(
|
|
863
|
+
verdict.reason,
|
|
864
|
+
final,
|
|
865
|
+
code=verdict.code,
|
|
866
|
+
stream_usage_chunks=_usage_chunks_from_updates(updates),
|
|
867
|
+
)
|
|
868
|
+
_strip_function_calls(final, updates)
|
|
869
|
+
_drop_empty_assistant_outputs(final, updates)
|
|
870
|
+
_select_response(final, inner_hooks)
|
|
871
|
+
await self._notify_attempt_accepted(final.messages or [])
|
|
872
|
+
for update in updates:
|
|
873
|
+
if not _is_stream_heartbeat(update):
|
|
874
|
+
yield update
|
|
875
|
+
active_result = None
|
|
876
|
+
return
|
|
877
|
+
|
|
878
|
+
previous_reason = verdict.reason
|
|
879
|
+
await self._notify_attempt_rejected(verdict.reason)
|
|
880
|
+
await result.aclose()
|
|
881
|
+
active_result = None
|
|
882
|
+
await self._note_retry(verdict.reason, attempt, validation=validation)
|
|
883
|
+
|
|
884
|
+
async def _close_active_result() -> None:
|
|
885
|
+
# A proxy left mid-attempt closes the inner stream that attempt
|
|
886
|
+
# was reading: after a failed or cancelled pull, and on an
|
|
887
|
+
# explicit close. It is the proxy's cleanup hook, never the
|
|
888
|
+
# generator's exit: finalization closes each abandoned generator
|
|
889
|
+
# in a task of its own, and an exit closing another generator
|
|
890
|
+
# would collide with that generator's own close.
|
|
891
|
+
nonlocal active_result
|
|
892
|
+
reading, active_result = active_result, None
|
|
893
|
+
if reading is not None:
|
|
894
|
+
await reading.aclose()
|
|
895
|
+
|
|
896
|
+
def _finalizer(_updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
|
|
897
|
+
if final_response is None:
|
|
898
|
+
raise RuntimeError("Streaming response validation ended without a final response.")
|
|
899
|
+
return final_response
|
|
900
|
+
|
|
901
|
+
proxy = ResponseStream(_updates(), finalizer=_finalizer).with_cleanup_hook(_close_active_result)
|
|
902
|
+
return proxy
|
|
903
|
+
|
|
904
|
+
# ------------------------------------------------------------------
|
|
905
|
+
# Internal helpers
|
|
906
|
+
# ------------------------------------------------------------------
|
|
907
|
+
|
|
908
|
+
def _is_final_attempt(self, attempt: int) -> bool:
|
|
909
|
+
return attempt >= self._max_retries
|
|
910
|
+
|
|
911
|
+
def _gives_up(
|
|
912
|
+
self,
|
|
913
|
+
verdict: ValidationResult,
|
|
914
|
+
*,
|
|
915
|
+
attempt: int,
|
|
916
|
+
previous_reason: str | None,
|
|
917
|
+
hosted_committed: bool,
|
|
918
|
+
service_side: bool,
|
|
919
|
+
) -> bool:
|
|
920
|
+
"""Whether this rejected verdict ends the cycle instead of retrying again.
|
|
921
|
+
|
|
922
|
+
Service-storage mode cannot replay in place — the whole run retries at
|
|
923
|
+
the pass boundary — so its budget is the instance-carried count and
|
|
924
|
+
reason rather than this loop's. It is the same decision either way, and
|
|
925
|
+
taking it here is what lets the recorded verdict state it.
|
|
926
|
+
"""
|
|
927
|
+
if service_side:
|
|
928
|
+
return self._should_give_up(
|
|
929
|
+
self._service_retry_count, verdict.reason, self._service_retry_reason, retryable=verdict.retryable
|
|
930
|
+
)
|
|
931
|
+
return hosted_committed or self._should_give_up(
|
|
932
|
+
attempt, verdict.reason, previous_reason, retryable=verdict.retryable
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
def _should_give_up(
|
|
936
|
+
self, attempt: int, reason: str, previous_reason: str | None, *, retryable: bool = True
|
|
937
|
+
) -> bool:
|
|
938
|
+
"""Stop retrying — non-retryable, out of attempts, or the producer is stuck.
|
|
939
|
+
|
|
940
|
+
Three short-circuit signals:
|
|
941
|
+
|
|
942
|
+
* **Non-retryable** — the validator marked the failure terminal
|
|
943
|
+
(``ValidationResult.retryable is False``, e.g. the response hit
|
|
944
|
+
the output token limit with no content). Re-issuing the same
|
|
945
|
+
request cannot help, so give up on the spot.
|
|
946
|
+
* **Final attempt** — :meth:`_is_final_attempt` (out of retries).
|
|
947
|
+
* **Repeat failure** — the new ``reason`` exactly matches the
|
|
948
|
+
prior attempt's. Same input + same reason almost always means
|
|
949
|
+
the producer is deterministic-stuck, so further retries are
|
|
950
|
+
pure latency tax. Logs a distinct warning so operators can
|
|
951
|
+
tell a fail-fast give-up apart from an exhausted-retry one.
|
|
952
|
+
"""
|
|
953
|
+
if not retryable:
|
|
954
|
+
return True
|
|
955
|
+
if self._is_final_attempt(attempt):
|
|
956
|
+
return True
|
|
957
|
+
if previous_reason is not None and reason == previous_reason:
|
|
958
|
+
logger.warning(
|
|
959
|
+
"Response validation failed identically on attempt %d/%d; "
|
|
960
|
+
"giving up early (deterministic failure suspected): %s",
|
|
961
|
+
attempt + 1,
|
|
962
|
+
self._max_retries + 1,
|
|
963
|
+
reason,
|
|
964
|
+
)
|
|
965
|
+
return True
|
|
966
|
+
return False
|
|
967
|
+
|
|
968
|
+
def _delay_for(self, attempt: int) -> float:
|
|
969
|
+
idx = min(attempt, len(self._backoff) - 1)
|
|
970
|
+
return max(0.0, float(self._backoff[idx]))
|
|
971
|
+
|
|
972
|
+
async def _note_retry(self, reason: str, attempt: int, *, validation: ValidationTrace | None = None) -> None:
|
|
973
|
+
"""Emit the retry event + log, then sleep the backoff delay."""
|
|
974
|
+
delay = self._delay_for(attempt)
|
|
975
|
+
if validation is not None:
|
|
976
|
+
await validation.retry_scheduled(delay_seconds=delay)
|
|
977
|
+
next_attempt = attempt + 1 # 1-based for the event / log
|
|
978
|
+
logger.warning(
|
|
979
|
+
"Response validation failed (attempt %d/%d), retrying in %.1fs: %s",
|
|
980
|
+
next_attempt,
|
|
981
|
+
self._max_retries,
|
|
982
|
+
delay,
|
|
983
|
+
reason,
|
|
984
|
+
)
|
|
985
|
+
if self._publish_retry is not None:
|
|
986
|
+
try:
|
|
987
|
+
await self._publish_retry(
|
|
988
|
+
RetryAttemptInfo(
|
|
989
|
+
reason=reason,
|
|
990
|
+
attempt=next_attempt,
|
|
991
|
+
max_attempts=self._max_retries,
|
|
992
|
+
delay_seconds=delay,
|
|
993
|
+
)
|
|
994
|
+
)
|
|
995
|
+
except Exception:
|
|
996
|
+
# Telemetry must never break the retry loop.
|
|
997
|
+
logger.exception("publish_retry callback raised")
|
|
998
|
+
if delay > 0:
|
|
999
|
+
await asyncio.sleep(delay)
|
|
1000
|
+
if validation is not None:
|
|
1001
|
+
await validation.retry_started()
|
|
1002
|
+
|
|
1003
|
+
def reset_service_retry_state(self) -> None:
|
|
1004
|
+
"""Discard carried service-side retry state.
|
|
1005
|
+
|
|
1006
|
+
Called internally whenever a validation cycle concludes (success,
|
|
1007
|
+
give-up, terminal), and by the orchestration pass observer at the start of
|
|
1008
|
+
every user-initiated run cycle — an aborted cycle (interrupt during
|
|
1009
|
+
outer backoff, unrelated exception) exits through none of the internal
|
|
1010
|
+
reset points, and its leftover count/reason must not judge the next
|
|
1011
|
+
independent run. Whole-run retry attempts WITHIN one cycle share the
|
|
1012
|
+
state; that is the point of carrying it.
|
|
1013
|
+
"""
|
|
1014
|
+
self._service_retry_count = 0
|
|
1015
|
+
self._service_retry_reason = None
|
|
1016
|
+
|
|
1017
|
+
def _service_side_failure(
|
|
1018
|
+
self,
|
|
1019
|
+
reason: str,
|
|
1020
|
+
response: ChatResponse,
|
|
1021
|
+
*,
|
|
1022
|
+
code: str,
|
|
1023
|
+
give_up: bool,
|
|
1024
|
+
stream_usage_chunks: list[dict[str, Any]] | None = None,
|
|
1025
|
+
) -> None:
|
|
1026
|
+
"""Route a service-side validation failure through the retry policy.
|
|
1027
|
+
|
|
1028
|
+
``give_up`` is the decision :meth:`_gives_up` already took against the
|
|
1029
|
+
instance-carried count/reason. Give-up is always terminal here: the
|
|
1030
|
+
service holds the unscrubbed conversation state, so returning a locally
|
|
1031
|
+
scrubbed response could desynchronize a stored continuation. The
|
|
1032
|
+
terminal raise resets the carried state so a later user-initiated run
|
|
1033
|
+
starts with a fresh budget.
|
|
1034
|
+
"""
|
|
1035
|
+
if give_up:
|
|
1036
|
+
self.reset_service_retry_state()
|
|
1037
|
+
self._raise_terminal(reason, response, code=code, stream_usage_chunks=stream_usage_chunks)
|
|
1038
|
+
self._service_retry_count += 1
|
|
1039
|
+
self._service_retry_reason = reason
|
|
1040
|
+
self._raise_service_retry(reason, response, stream_usage_chunks=stream_usage_chunks)
|
|
1041
|
+
|
|
1042
|
+
def _raise_terminal(
|
|
1043
|
+
self,
|
|
1044
|
+
reason: str,
|
|
1045
|
+
response: ChatResponse,
|
|
1046
|
+
*,
|
|
1047
|
+
code: str,
|
|
1048
|
+
stream_usage_chunks: list[dict[str, Any]] | None = None,
|
|
1049
|
+
) -> None:
|
|
1050
|
+
"""Raise a terminal validation failure into the pass observer error path.
|
|
1051
|
+
|
|
1052
|
+
A filtered response is raised as the provider's own content-filter
|
|
1053
|
+
failure: it is classified as filtered, and a live continuation token
|
|
1054
|
+
names a finished response that a retry must not poll.
|
|
1055
|
+
"""
|
|
1056
|
+
logger.warning("Response validation failed terminally (giving up): %s", reason)
|
|
1057
|
+
usage_details = None
|
|
1058
|
+
if stream_usage_chunks is not None:
|
|
1059
|
+
normalized = normalize_stream_usage(stream_usage_chunks)
|
|
1060
|
+
usage_details = dict(normalized) if normalized else None
|
|
1061
|
+
if usage_details is None and response.usage_details:
|
|
1062
|
+
usage_details = dict(response.usage_details)
|
|
1063
|
+
error = TerminalResponseValidationError(reason, usage_details=usage_details)
|
|
1064
|
+
if code == ValidationReason.CONTENT_FILTERED:
|
|
1065
|
+
raise error from ProviderResponseError(
|
|
1066
|
+
"content_filter", reason, retryable=False, invalidates_continuation_token=True
|
|
1067
|
+
)
|
|
1068
|
+
raise error
|
|
1069
|
+
|
|
1070
|
+
def _raise_service_retry(
|
|
1071
|
+
self,
|
|
1072
|
+
reason: str,
|
|
1073
|
+
response: ChatResponse,
|
|
1074
|
+
*,
|
|
1075
|
+
stream_usage_chunks: list[dict[str, Any]] | None = None,
|
|
1076
|
+
) -> None:
|
|
1077
|
+
"""Raise a stored validation failure to the whole-run retry owner."""
|
|
1078
|
+
usage_details = None
|
|
1079
|
+
if stream_usage_chunks is not None:
|
|
1080
|
+
normalized = normalize_stream_usage(stream_usage_chunks)
|
|
1081
|
+
usage_details = dict(normalized) if normalized else None
|
|
1082
|
+
if usage_details is None and response.usage_details:
|
|
1083
|
+
usage_details = dict(response.usage_details)
|
|
1084
|
+
raise RetryableResponseValidationError(
|
|
1085
|
+
reason,
|
|
1086
|
+
exemption=ValidationRetryExemption(
|
|
1087
|
+
attempt=self._service_retry_count,
|
|
1088
|
+
max_attempts=self._max_retries,
|
|
1089
|
+
delay_seconds=math.ceil(self._delay_for(self._service_retry_count - 1)),
|
|
1090
|
+
),
|
|
1091
|
+
usage_details=usage_details,
|
|
1092
|
+
hosted_commits=_hosted_commit_labels(response),
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
# ---------------------------------------------------------------------------
|
|
1097
|
+
# Inner-stream finaliser + replay helpers
|
|
1098
|
+
# ---------------------------------------------------------------------------
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _collect_and_clear_hooks(
|
|
1102
|
+
stream: ResponseStream[ChatResponseUpdate, ChatResponse],
|
|
1103
|
+
) -> list[Callable[[ChatResponse], Any]]:
|
|
1104
|
+
"""Walk a (possibly wrapped) ResponseStream chain; collect + clear hooks.
|
|
1105
|
+
|
|
1106
|
+
``BaseChatClient.get_response`` may wrap a provider's stream in
|
|
1107
|
+
``ResponseStream.from_awaitable`` (e.g. when a compaction override is
|
|
1108
|
+
active), in which case the hook installed by ``_inner_get_response``
|
|
1109
|
+
sits on the *inner* stream, not the outer wrapper. Walk the
|
|
1110
|
+
``_inner_stream`` chain, collecting hooks from each layer and
|
|
1111
|
+
clearing them so neither the layer's own ``get_final_response`` nor
|
|
1112
|
+
the auto-finalise path inside ``__anext__`` fires them during our
|
|
1113
|
+
preview drain.
|
|
1114
|
+
|
|
1115
|
+
The caller should ``await stream`` first so ``_inner_stream`` is
|
|
1116
|
+
populated on any ``from_awaitable`` wrappers.
|
|
1117
|
+
"""
|
|
1118
|
+
hooks_by_layer: list[list[Callable[[ChatResponse], Any]]] = []
|
|
1119
|
+
seen: set[int] = set()
|
|
1120
|
+
cur: ResponseStream[Any, Any] | None = stream
|
|
1121
|
+
while cur is not None and id(cur) not in seen:
|
|
1122
|
+
seen.add(id(cur))
|
|
1123
|
+
hooks = cur._result_hooks
|
|
1124
|
+
if hooks:
|
|
1125
|
+
hooks_by_layer.append(list(hooks))
|
|
1126
|
+
cur._result_hooks = []
|
|
1127
|
+
cur = getattr(cur, "_inner_stream", None)
|
|
1128
|
+
# ResponseStream finalizes wrapped streams from the innermost layer outward.
|
|
1129
|
+
# Recreate that phase order when all hooks move onto the validating proxy.
|
|
1130
|
+
return [hook for hooks in reversed(hooks_by_layer) for hook in hooks]
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
async def _preview_finalize(
|
|
1134
|
+
inner: ResponseStream[ChatResponseUpdate, ChatResponse],
|
|
1135
|
+
updates: Sequence[ChatResponseUpdate],
|
|
1136
|
+
) -> ChatResponse:
|
|
1137
|
+
"""Produce a :class:`ChatResponse` from *updates* for validation only.
|
|
1138
|
+
|
|
1139
|
+
Uses the inner stream's ``_finalizer`` if set (so provider-specific
|
|
1140
|
+
finalisation logic, e.g. ``output_format_type`` parsing, still runs)
|
|
1141
|
+
but deliberately does **not** invoke the inner's ``_result_hooks``.
|
|
1142
|
+
Those hooks are reserved for the caller-visible finalisation that
|
|
1143
|
+
runs once we've decided to return the response.
|
|
1144
|
+
"""
|
|
1145
|
+
finalizer = getattr(inner, "_finalizer", None)
|
|
1146
|
+
if finalizer is not None:
|
|
1147
|
+
result = finalizer(updates)
|
|
1148
|
+
if inspect.isawaitable(result):
|
|
1149
|
+
result = await result
|
|
1150
|
+
if isinstance(result, ChatResponse):
|
|
1151
|
+
return result
|
|
1152
|
+
# Fallback: build the response directly. Keeps the middleware
|
|
1153
|
+
# robust against custom ResponseStream subclasses that stash the
|
|
1154
|
+
# finalizer elsewhere.
|
|
1155
|
+
return ChatResponse.from_updates(list(updates))
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _is_stream_heartbeat(update: ChatResponseUpdate) -> bool:
|
|
1159
|
+
"""Return whether *update* is the explicit opaque transport heartbeat."""
|
|
1160
|
+
return update.is_transport_heartbeat
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _stream_heartbeat_for(update: ChatResponseUpdate) -> ChatResponseUpdate:
|
|
1164
|
+
"""Return one semantics-free progress update for every provider update.
|
|
1165
|
+
|
|
1166
|
+
The continuation token rides along: it is transport bookkeeping, not
|
|
1167
|
+
model content, and the retry owner must learn a background response id
|
|
1168
|
+
as soon as the provider announces it — content buffering must not delay
|
|
1169
|
+
that past a mid-stream disconnect.
|
|
1170
|
+
"""
|
|
1171
|
+
heartbeat = ChatResponseUpdate.transport_heartbeat()
|
|
1172
|
+
heartbeat.continuation_token = update.continuation_token
|
|
1173
|
+
return heartbeat
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _strip_function_calls(
|
|
1177
|
+
response: ChatResponse,
|
|
1178
|
+
updates: list[ChatResponseUpdate],
|
|
1179
|
+
) -> None:
|
|
1180
|
+
"""Remove actionable ``function_call`` contents from *response* and *updates*.
|
|
1181
|
+
|
|
1182
|
+
Used on the validation **give-up** path only — never on a valid
|
|
1183
|
+
response, since stripping would suppress real tool calls the model
|
|
1184
|
+
legitimately wants to execute. Without this, an exhausted-but-
|
|
1185
|
+
still-malformed response carrying a real ``function_call`` would be
|
|
1186
|
+
handed to the tool loop, the loop would iterate, and a model stuck
|
|
1187
|
+
in a bad state would re-trigger the full validation cycle every
|
|
1188
|
+
iteration. Stripping forces ``_extract_function_calls`` to return
|
|
1189
|
+
``[]`` so the loop exits cleanly.
|
|
1190
|
+
|
|
1191
|
+
Mutates ``Message.contents`` and ``ChatResponseUpdate.contents`` in
|
|
1192
|
+
place. Informational hosted calls and other content types (text,
|
|
1193
|
+
function_result, image, …) are preserved.
|
|
1194
|
+
"""
|
|
1195
|
+
for msg in response.messages:
|
|
1196
|
+
if any(c.type == "function_call" and not c.informational_only for c in msg.contents):
|
|
1197
|
+
msg.contents = [c for c in msg.contents if c.type != "function_call" or c.informational_only]
|
|
1198
|
+
for upd in updates:
|
|
1199
|
+
if upd.contents and any(c.type == "function_call" and not c.informational_only for c in upd.contents):
|
|
1200
|
+
upd.contents = [c for c in upd.contents if c.type != "function_call" or c.informational_only]
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _usage_chunks_from_updates(updates: Sequence[ChatResponseUpdate]) -> list[dict[str, Any]]:
|
|
1204
|
+
"""Return provider usage payloads from streamed updates."""
|
|
1205
|
+
return [
|
|
1206
|
+
dict(content.usage_details)
|
|
1207
|
+
for update in updates
|
|
1208
|
+
for content in update.contents
|
|
1209
|
+
if content.type == "usage" and content.usage_details
|
|
1210
|
+
]
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _drop_empty_assistant_outputs(
|
|
1214
|
+
response: ChatResponse,
|
|
1215
|
+
updates: list[ChatResponseUpdate],
|
|
1216
|
+
) -> None:
|
|
1217
|
+
"""Drop structurally-empty assistant messages and their updates.
|
|
1218
|
+
|
|
1219
|
+
Run on **every** response (valid + give-up). Two reasons:
|
|
1220
|
+
|
|
1221
|
+
1. ``DefaultResponseValidator`` only inspects the *final* assistant
|
|
1222
|
+
message, so a response shaped like ``[empty, valid]`` passes
|
|
1223
|
+
validation while still carrying a stray ``content: []`` leading
|
|
1224
|
+
message that would land in persisted history.
|
|
1225
|
+
2. The kernel streaming finalizer rebuilds the final
|
|
1226
|
+
persisted response with ``ChatResponse.from_updates(updates)``
|
|
1227
|
+
from stream updates. If we only scrub
|
|
1228
|
+
``response.messages`` the streaming path would re-create the
|
|
1229
|
+
empty message from the leftover updates and we'd be back where
|
|
1230
|
+
we started. Mirroring the drop on *updates* prevents that.
|
|
1231
|
+
|
|
1232
|
+
"Empty" means: ``contents`` is ``[]`` OR every content item is
|
|
1233
|
+
text and the concatenated text is empty / whitespace-only. Any
|
|
1234
|
+
non-text content (image, function_result, audio, …) is treated as
|
|
1235
|
+
a useful payload and the message/update is kept.
|
|
1236
|
+
|
|
1237
|
+
The update-level pass is **group-aware**: updates are grouped by
|
|
1238
|
+
``(role, message_id)`` using the same boundary rules as the kernel
|
|
1239
|
+
stream grouping logic, and an entire group is
|
|
1240
|
+
dropped only when every update in it contributes empty content.
|
|
1241
|
+
A real provider's terminal "stop" update (empty contents +
|
|
1242
|
+
``finish_reason``) typically shares the previous text update's
|
|
1243
|
+
group, so it survives the drop and its metadata reaches the
|
|
1244
|
+
rebuild — only fully-empty placeholder groups (e.g. an unfilled
|
|
1245
|
+
``message_id`` boundary) are removed.
|
|
1246
|
+
|
|
1247
|
+
Mutates ``response.messages`` and *updates* in place.
|
|
1248
|
+
"""
|
|
1249
|
+
response.messages = [m for m in response.messages if not _is_empty_assistant_message(m)]
|
|
1250
|
+
|
|
1251
|
+
if not updates:
|
|
1252
|
+
return
|
|
1253
|
+
keep_mask = _compute_update_keep_mask(updates)
|
|
1254
|
+
cleaned: list[ChatResponseUpdate] = [u for u, keep in zip(updates, keep_mask, strict=True) if keep]
|
|
1255
|
+
updates.clear()
|
|
1256
|
+
updates.extend(cleaned)
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def _compute_update_keep_mask(updates: list[ChatResponseUpdate]) -> list[bool]:
|
|
1260
|
+
"""Group *updates* by their effective ``(role, message_id)`` and decide
|
|
1261
|
+
which updates to keep.
|
|
1262
|
+
|
|
1263
|
+
Mirrors ``_process_update``'s message-boundary rules: a new group
|
|
1264
|
+
starts when the update has a ``message_id`` that differs from the
|
|
1265
|
+
current group's ``message_id``, OR when the update has a ``role``
|
|
1266
|
+
that differs from the current group's role. Otherwise the update
|
|
1267
|
+
appends to the current group.
|
|
1268
|
+
|
|
1269
|
+
A group is "empty assistant" if its effective role resolves to
|
|
1270
|
+
assistant (or unset, since ``_process_update`` defaults to
|
|
1271
|
+
assistant) AND every update in the group has empty / whitespace-
|
|
1272
|
+
only-text contents. Such groups are dropped wholesale — including
|
|
1273
|
+
any metadata-bearing terminal updates inside them, since with no
|
|
1274
|
+
text/non-text content there is nothing for the metadata to attach
|
|
1275
|
+
to in the rebuild.
|
|
1276
|
+
|
|
1277
|
+
Returns a boolean mask of the same length as *updates*.
|
|
1278
|
+
"""
|
|
1279
|
+
if not updates:
|
|
1280
|
+
return []
|
|
1281
|
+
|
|
1282
|
+
# Pass 1: assign each update to a group index, mirroring
|
|
1283
|
+
# ``_process_update`` exactly. Two key invariants from the kernel
|
|
1284
|
+
# stream rebuild logic:
|
|
1285
|
+
#
|
|
1286
|
+
# * Every new message begins life as ``Message("assistant", [])`` —
|
|
1287
|
+
# default role assistant, no message_id. The current update's
|
|
1288
|
+
# ``role`` / ``message_id`` then OVERRIDE that placeholder state.
|
|
1289
|
+
# So whenever we open a new group we MUST reset ``cur_role`` to
|
|
1290
|
+
# ``"assistant"`` and ``cur_message_id`` to ``None`` *before*
|
|
1291
|
+
# applying the new update — otherwise a role-less new-message_id
|
|
1292
|
+
# update inherits the previous group's role, and a same-id
|
|
1293
|
+
# follow-up update can split incorrectly.
|
|
1294
|
+
# * Boundary checks reference the previous message's *final* state
|
|
1295
|
+
# (after all its updates have been applied). ``cur_role`` /
|
|
1296
|
+
# ``cur_message_id`` track that state for the current group.
|
|
1297
|
+
group_idx: list[int] = []
|
|
1298
|
+
group_role: list[str] = []
|
|
1299
|
+
cur_role: str = "assistant"
|
|
1300
|
+
cur_message_id: str | None = None
|
|
1301
|
+
cur_idx = -1
|
|
1302
|
+
for upd in updates:
|
|
1303
|
+
new_group = (
|
|
1304
|
+
cur_idx < 0
|
|
1305
|
+
or (upd.message_id and cur_message_id and upd.message_id != cur_message_id)
|
|
1306
|
+
or (upd.role and upd.role != cur_role)
|
|
1307
|
+
)
|
|
1308
|
+
if new_group:
|
|
1309
|
+
cur_idx += 1
|
|
1310
|
+
# New placeholder: kernel default role + no inherited id.
|
|
1311
|
+
cur_role = "assistant"
|
|
1312
|
+
cur_message_id = None
|
|
1313
|
+
group_role.append(cur_role)
|
|
1314
|
+
group_idx.append(cur_idx)
|
|
1315
|
+
if upd.role:
|
|
1316
|
+
cur_role = upd.role
|
|
1317
|
+
# Update the group's recorded role so subsequent comparisons
|
|
1318
|
+
# use the latest known role for this group.
|
|
1319
|
+
group_role[cur_idx] = cur_role
|
|
1320
|
+
if upd.message_id:
|
|
1321
|
+
cur_message_id = upd.message_id
|
|
1322
|
+
|
|
1323
|
+
# Pass 2: per-group, check if all updates contribute empty content.
|
|
1324
|
+
group_count = cur_idx + 1
|
|
1325
|
+
group_has_content: list[bool] = [False] * group_count
|
|
1326
|
+
for upd, gi in zip(updates, group_idx, strict=True):
|
|
1327
|
+
if _update_has_useful_content(upd):
|
|
1328
|
+
group_has_content[gi] = True
|
|
1329
|
+
|
|
1330
|
+
# Pass 3: build keep mask. Only assistant groups are scrubbed —
|
|
1331
|
+
# tool / user / system groups are kept regardless of content so we
|
|
1332
|
+
# never drop kernel- or caller-injected non-assistant turns.
|
|
1333
|
+
keep_mask: list[bool] = []
|
|
1334
|
+
for gi in group_idx:
|
|
1335
|
+
is_assistant_group = group_role[gi] == "assistant"
|
|
1336
|
+
keep_mask.append((not is_assistant_group) or group_has_content[gi])
|
|
1337
|
+
return keep_mask
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _update_has_useful_content(upd: ChatResponseUpdate) -> bool:
|
|
1341
|
+
"""Return ``True`` if *upd*'s contents include anything non-empty.
|
|
1342
|
+
|
|
1343
|
+
Non-text content (image, function_call, function_result, usage, …)
|
|
1344
|
+
counts as useful. Text content counts only if the concatenated text
|
|
1345
|
+
has at least one non-whitespace character. Empty contents → not
|
|
1346
|
+
useful.
|
|
1347
|
+
"""
|
|
1348
|
+
contents = upd.contents or []
|
|
1349
|
+
if not contents:
|
|
1350
|
+
return False
|
|
1351
|
+
text_acc: list[str] = []
|
|
1352
|
+
for c in contents:
|
|
1353
|
+
ctype = c.type
|
|
1354
|
+
if ctype != "text":
|
|
1355
|
+
return True
|
|
1356
|
+
text_acc.append(c.text or "")
|
|
1357
|
+
return bool("".join(text_acc).strip())
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _is_empty_assistant_message(msg: Message) -> bool:
|
|
1361
|
+
"""Return ``True`` if *msg* is an assistant message with no useful payload.
|
|
1362
|
+
|
|
1363
|
+
"No useful payload" means:
|
|
1364
|
+
|
|
1365
|
+
* ``contents`` is empty (``[]``), OR
|
|
1366
|
+
* every content item is text and the concatenated text is empty or
|
|
1367
|
+
whitespace-only.
|
|
1368
|
+
|
|
1369
|
+
A message carrying any non-text content (image, function_result,
|
|
1370
|
+
audio, …) is NOT considered empty even if the text fragments are
|
|
1371
|
+
blank — preserving multimodal payloads that may still be useful.
|
|
1372
|
+
"""
|
|
1373
|
+
if msg.role != "assistant":
|
|
1374
|
+
return False
|
|
1375
|
+
if not msg.contents:
|
|
1376
|
+
return True
|
|
1377
|
+
for c in msg.contents:
|
|
1378
|
+
if c.type != "text":
|
|
1379
|
+
return False
|
|
1380
|
+
return not "".join((c.text or "") for c in msg.contents).strip()
|