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,1616 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""SessionHistoryManager — single owner of all ``chrys_history`` state mutations.
|
|
4
|
+
|
|
5
|
+
Centralizes the 16+ history-mutation methods that were scattered across
|
|
6
|
+
AgentEngine. All marker insertion, message trimming, metadata persistence,
|
|
7
|
+
and state repair operations go through this class.
|
|
8
|
+
|
|
9
|
+
The manager holds a reference to the live ``chrys_history`` provider state
|
|
10
|
+
dict (shared with CompressibleHistoryProvider). Mutations
|
|
11
|
+
are visible to Agent's history provider immediately.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from collections.abc import Hashable, Mapping
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast
|
|
22
|
+
|
|
23
|
+
from chrys.foundation.i18n.formatting import format_message
|
|
24
|
+
from chrys.foundation.models.history_markers import (
|
|
25
|
+
AWAITING_SUB_AGENTS_MESSAGE,
|
|
26
|
+
EXECUTION_INTERRUPTED_MESSAGE,
|
|
27
|
+
HistoryMarkerKind,
|
|
28
|
+
copy_reminder_record,
|
|
29
|
+
)
|
|
30
|
+
from chrys.foundation.models.turns import (
|
|
31
|
+
UserMessageKind,
|
|
32
|
+
current_turn_start,
|
|
33
|
+
is_continuation_message,
|
|
34
|
+
user_text_matches,
|
|
35
|
+
)
|
|
36
|
+
from chrys.foundation.platform.files import surrogate_safe_text
|
|
37
|
+
from chrys.foundation.tool_invocation_order import read_tool_invocation_order
|
|
38
|
+
from chrys.foundation.tool_kinds import KIND_SUB_AGENT
|
|
39
|
+
from chrys.foundation.trajectory.metadata import ensure_analytics_item_id
|
|
40
|
+
from chrys.kernel import Message
|
|
41
|
+
from chrys.kernel.exchanges import (
|
|
42
|
+
PREVALIDATION_ERROR_KINDS,
|
|
43
|
+
EmptyIdPolicy,
|
|
44
|
+
LiveAccessor,
|
|
45
|
+
NoneIdPolicy,
|
|
46
|
+
PairingPolicy,
|
|
47
|
+
iter_exchanges,
|
|
48
|
+
pair_results,
|
|
49
|
+
)
|
|
50
|
+
from chrys.service.agent_middleware.events.tool_events import ToolBatchRecord
|
|
51
|
+
from chrys.service.agent_middleware.injection import ConsumedInjection
|
|
52
|
+
from chrys.service.session.message_metadata import (
|
|
53
|
+
TOOL_RESULT_METADATA_KEY,
|
|
54
|
+
normalize_created_at,
|
|
55
|
+
persisted_tool_call_kind,
|
|
56
|
+
stamp_message_created_at,
|
|
57
|
+
)
|
|
58
|
+
from chrys.service.session.sub_agent_logs import SUB_AGENT_RESTORE_CONSUMED_KEY
|
|
59
|
+
from chrys.service.trajectory.items import ensure_history_item_ids
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING:
|
|
62
|
+
from chrys.kernel import Content
|
|
63
|
+
from chrys.kernel.exchanges import Exchange, ExchangePairing, Occurrence, PairingKey
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger(__name__)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MergeableLoopRecorder(Protocol):
|
|
69
|
+
"""Recorder view consumed by history recovery."""
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def initial_count(self) -> int | None: ...
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def captured_count(self) -> int | None: ...
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def loop_messages(self) -> list[Message] | None: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_USER_APPROVAL_STATUSES = frozenset({"user_approved", "user_rejected"})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _select_message_approval_from_contents(message: Message) -> dict[str, str] | None:
|
|
85
|
+
"""Return the best legacy message-level approval from function-call contents."""
|
|
86
|
+
selected: dict[str, str] | None = None
|
|
87
|
+
for content in message.contents:
|
|
88
|
+
if content.type != "function_call" or content.informational_only:
|
|
89
|
+
continue
|
|
90
|
+
approval = content.additional_properties.get("_approval")
|
|
91
|
+
if not isinstance(approval, dict):
|
|
92
|
+
continue
|
|
93
|
+
if selected is None or (
|
|
94
|
+
selected.get("status") not in _USER_APPROVAL_STATUSES and approval.get("status") in _USER_APPROVAL_STATUSES
|
|
95
|
+
):
|
|
96
|
+
selected = approval
|
|
97
|
+
return selected
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _sync_message_approval_from_contents(message: Message) -> None:
|
|
101
|
+
"""Refresh legacy message-level approval after function-call contents change."""
|
|
102
|
+
approval = _select_message_approval_from_contents(message)
|
|
103
|
+
if approval is None:
|
|
104
|
+
message.additional_properties.pop("_approval", None)
|
|
105
|
+
else:
|
|
106
|
+
message.additional_properties["_approval"] = approval
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _sub_agent_result_metadata(record: dict[str, Any]) -> dict[str, str]:
|
|
110
|
+
metadata: dict[str, str] = {}
|
|
111
|
+
invocation_id = record.get("invocation_id")
|
|
112
|
+
if isinstance(invocation_id, str) and invocation_id:
|
|
113
|
+
metadata["sub_agent_invocation_id"] = invocation_id
|
|
114
|
+
log_file = record.get("sub_agent_log_file")
|
|
115
|
+
if isinstance(log_file, str) and log_file:
|
|
116
|
+
metadata["sub_agent_log_file"] = log_file
|
|
117
|
+
return metadata
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _content_is_image(content: Any) -> bool:
|
|
121
|
+
media_type = content.media_type
|
|
122
|
+
return isinstance(media_type, str) and media_type.startswith("image/")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _message_has_image_content(message: Message) -> bool:
|
|
126
|
+
return any(_content_is_image(content) for content in message.contents)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _contents_have_image_content(contents: list[Any]) -> bool:
|
|
130
|
+
return any(not isinstance(content, str) and _content_is_image(content) for content in contents)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Trim's pairing row: actionable local function calls only (hosted
|
|
134
|
+
# informational calls have no function_result by design), function results
|
|
135
|
+
# only, id-less calls answered solely by id-less PREVALIDATION failures
|
|
136
|
+
# (complete-or-delete fails closed), empty-id calls paired positionally,
|
|
137
|
+
# and malformed non-string ids normalized by their string form.
|
|
138
|
+
_TRIM_PAIRING_POLICY = PairingPolicy(
|
|
139
|
+
call_types=frozenset({"function_call"}),
|
|
140
|
+
include_informational_calls=False,
|
|
141
|
+
result_types=frozenset({"function_result"}),
|
|
142
|
+
none_id=NoneIdPolicy.POSITIONAL_FAILURES,
|
|
143
|
+
empty_id=EmptyIdPolicy.POSITIONAL,
|
|
144
|
+
malformed_id="stringify",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Approval's pairing row: local actionable calls only; id-less calls and
|
|
148
|
+
# results are UNPAIRABLE occurrences — visible to eligibility and the
|
|
149
|
+
# block-wide falsy failure boolean without consuming pair slots; malformed
|
|
150
|
+
# ids compare by string form at the pairing layer while every
|
|
151
|
+
# decision-matching stage keeps its raw reads.
|
|
152
|
+
_APPROVAL_PAIRING_POLICY = PairingPolicy(
|
|
153
|
+
call_types=frozenset({"function_call"}),
|
|
154
|
+
include_informational_calls=False,
|
|
155
|
+
result_types=frozenset({"function_result"}),
|
|
156
|
+
none_id=NoneIdPolicy.UNPAIRABLE_OCCURRENCE,
|
|
157
|
+
empty_id=EmptyIdPolicy.UNPAIRABLE_OCCURRENCE,
|
|
158
|
+
malformed_id="stringify",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _approval_exclusions(
|
|
163
|
+
messages: list[Message], exchange: Exchange, accessor: LiveAccessor
|
|
164
|
+
) -> tuple[set[PairingKey], bool]:
|
|
165
|
+
"""One exchange's never-approvable evidence, from the eligible view.
|
|
166
|
+
|
|
167
|
+
Returns the truthy keys answered by an ELIGIBLE prevalidation-failure
|
|
168
|
+
result, plus the block-wide falsy boolean: whether any eligible id-less
|
|
169
|
+
failure occurrence exists in either falsy stream (stream-blind — an
|
|
170
|
+
unpairable failure can only answer SOME id-less call of the block, so
|
|
171
|
+
every id-less call fails closed).
|
|
172
|
+
"""
|
|
173
|
+
pairing = pair_results(messages, exchange, accessor, _APPROVAL_PAIRING_POLICY)
|
|
174
|
+
excluded_keys = {
|
|
175
|
+
key
|
|
176
|
+
for key, occurrences in pairing.eligible_results.truthy.items()
|
|
177
|
+
if any(occurrence.error_kind in PREVALIDATION_ERROR_KINDS for occurrence in occurrences)
|
|
178
|
+
}
|
|
179
|
+
falsy_failure = any(
|
|
180
|
+
occurrence.error_kind in PREVALIDATION_ERROR_KINDS
|
|
181
|
+
for stream in pairing.eligible_results.falsy.values()
|
|
182
|
+
for occurrence in stream
|
|
183
|
+
)
|
|
184
|
+
return excluded_keys, falsy_failure
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Sub-agent repair's pairing row: local function calls and results only.
|
|
188
|
+
# None-id calls are IGNORED (sub-agent calls carry minted persisted ids, so
|
|
189
|
+
# an id-less call is not repair's to answer); empty-id calls pair
|
|
190
|
+
# positionally against empty-id results; malformed ids pair by string form
|
|
191
|
+
# while record lookups and synthetic results keep the original value.
|
|
192
|
+
_REPAIR_PAIRING_POLICY = PairingPolicy(
|
|
193
|
+
call_types=frozenset({"function_call"}),
|
|
194
|
+
include_informational_calls=False,
|
|
195
|
+
result_types=frozenset({"function_result"}),
|
|
196
|
+
none_id=NoneIdPolicy.IGNORE,
|
|
197
|
+
empty_id=EmptyIdPolicy.POSITIONAL,
|
|
198
|
+
malformed_id="stringify",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _repair_call_entries(pairing: ExchangePairing) -> list[tuple[Occurrence, object, bool]]:
|
|
203
|
+
"""One exchange's repairable call occurrences in transcript order.
|
|
204
|
+
|
|
205
|
+
Returns ``(occurrence, truthy pairing key or None, answered)`` per call.
|
|
206
|
+
Truthy keys are answered at KEY level (``answered_keys``): duplicate ids
|
|
207
|
+
inside one response are shadowed by dispatch and one result answers
|
|
208
|
+
every occurrence, so sibling duplicates are one dangling invocation.
|
|
209
|
+
Empty-id calls are answered per POSITIONAL slot — each unanswered
|
|
210
|
+
occurrence is its own dangling invocation.
|
|
211
|
+
"""
|
|
212
|
+
answered_keys = pairing.answered_keys
|
|
213
|
+
entries: list[tuple[Occurrence, object, bool]] = []
|
|
214
|
+
for key, assignments in pairing.truthy_assignments.items():
|
|
215
|
+
for call, _result in assignments:
|
|
216
|
+
entries.append((call, key, key in answered_keys))
|
|
217
|
+
for call, result in pairing.falsy_assignments["empty"]:
|
|
218
|
+
entries.append((call, None, result is not None))
|
|
219
|
+
entries.sort(key=lambda entry: (entry[0].message_index, entry[0].content_index))
|
|
220
|
+
return entries
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _trim_exchange_siblings(messages: list[Message], exchange: Exchange, pairing: ExchangePairing) -> bool:
|
|
224
|
+
"""Trim one exchange's unanswered calls, sibling by sibling.
|
|
225
|
+
|
|
226
|
+
Every response sibling is checked against the exchange-wide pairing
|
|
227
|
+
(the shared output block and embedded results answer any sibling's
|
|
228
|
+
calls). Per sibling, the deletion branches mirror what trim has always
|
|
229
|
+
applied to a single message: all answered → keep; partially answered →
|
|
230
|
+
drop only the unanswered call contents; nothing answered → pop the
|
|
231
|
+
whole message, unless a hosted informational call shields it into the
|
|
232
|
+
surgical branch.
|
|
233
|
+
|
|
234
|
+
Truthy ids are answered at KEY level: duplicate ids are shadowed by
|
|
235
|
+
dispatch and one result answers every occurrence, so no duplicate of
|
|
236
|
+
an answered id is ever trimmed. Falsy ids stay occurrence-level —
|
|
237
|
+
each unanswered positional slot is its own dangling invocation.
|
|
238
|
+
|
|
239
|
+
Returns True when any call-carrying sibling survives (complete,
|
|
240
|
+
surgically trimmed, or shielded) — trim stops there; False when every
|
|
241
|
+
call-carrying sibling was popped, letting the caller continue walking
|
|
242
|
+
into earlier history exactly as the message-by-message walk did. A
|
|
243
|
+
run head with no in-policy call occurrences (informational-only,
|
|
244
|
+
out-of-scope call types, or plain text) also survives: the walk has
|
|
245
|
+
always stopped at an assistant run it has no actionable calls in.
|
|
246
|
+
"""
|
|
247
|
+
matched_by_sibling: dict[int, list[Occurrence]] = {}
|
|
248
|
+
unmatched_by_sibling: dict[int, list[Occurrence]] = {}
|
|
249
|
+
answered_keys = pairing.answered_keys
|
|
250
|
+
for key, assignments in pairing.truthy_assignments.items():
|
|
251
|
+
bucket = matched_by_sibling if key in answered_keys else unmatched_by_sibling
|
|
252
|
+
for call, _result in assignments:
|
|
253
|
+
bucket.setdefault(call.message_index, []).append(call)
|
|
254
|
+
for assignments in pairing.falsy_assignments.values():
|
|
255
|
+
for call, result in assignments:
|
|
256
|
+
bucket = matched_by_sibling if result is not None else unmatched_by_sibling
|
|
257
|
+
bucket.setdefault(call.message_index, []).append(call)
|
|
258
|
+
|
|
259
|
+
head_index = exchange.response_indices[0]
|
|
260
|
+
survived = head_index not in matched_by_sibling and head_index not in unmatched_by_sibling
|
|
261
|
+
for sibling_index in reversed(exchange.response_indices):
|
|
262
|
+
matched = matched_by_sibling.get(sibling_index, [])
|
|
263
|
+
unmatched = unmatched_by_sibling.get(sibling_index, [])
|
|
264
|
+
if not unmatched:
|
|
265
|
+
if matched:
|
|
266
|
+
survived = True
|
|
267
|
+
continue
|
|
268
|
+
message = messages[sibling_index]
|
|
269
|
+
informational = any(c.type == "function_call" and c.informational_only for c in message.contents)
|
|
270
|
+
if matched or informational:
|
|
271
|
+
dropped = {occurrence.content_index for occurrence in unmatched}
|
|
272
|
+
message.contents = [c for index, c in enumerate(message.contents) if index not in dropped]
|
|
273
|
+
_sync_message_approval_from_contents(message)
|
|
274
|
+
survived = True
|
|
275
|
+
continue
|
|
276
|
+
messages.pop(sibling_index)
|
|
277
|
+
return survived
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _effective_metadata_start_index(messages: list[Message], start_index: int | None) -> int:
|
|
281
|
+
"""Clamp a post-run metadata start index to the current logical turn."""
|
|
282
|
+
if start_index is None or start_index < 0:
|
|
283
|
+
start_index = 0
|
|
284
|
+
elif start_index > len(messages):
|
|
285
|
+
start_index = len(messages)
|
|
286
|
+
|
|
287
|
+
# Tools like compress_context can remove earlier messages before
|
|
288
|
+
# post-run metadata is persisted. The previous turn marker is the
|
|
289
|
+
# stable logical boundary for a normal run, so use it as a floor when
|
|
290
|
+
# it lands before the numeric index.
|
|
291
|
+
if start_index > 0:
|
|
292
|
+
turn_start = current_turn_start(messages)
|
|
293
|
+
if 0 < turn_start < start_index:
|
|
294
|
+
start_index = turn_start
|
|
295
|
+
return start_index
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def stamp_history_item_ids(state: dict[str, Any]) -> None:
|
|
299
|
+
"""Give restored history occurrence identities before it becomes live."""
|
|
300
|
+
messages = state.get("messages")
|
|
301
|
+
if isinstance(messages, list) and messages:
|
|
302
|
+
ensure_history_item_ids(messages)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class SessionHistoryManager:
|
|
306
|
+
"""Owns all mutations to the ``chrys_history`` session state dict.
|
|
307
|
+
|
|
308
|
+
Usage::
|
|
309
|
+
|
|
310
|
+
history = SessionHistoryManager()
|
|
311
|
+
stamp_history_item_ids(executor.history_state)
|
|
312
|
+
history.bind(executor.history_state)
|
|
313
|
+
history.insert_turn_marker()
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
def __init__(self) -> None:
|
|
317
|
+
self._state: dict[str, Any] | None = None
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def is_bound(self) -> bool:
|
|
321
|
+
"""Whether this manager has been bound to a state dict."""
|
|
322
|
+
return self._state is not None
|
|
323
|
+
|
|
324
|
+
def bind(self, state: dict[str, Any]) -> None:
|
|
325
|
+
"""Bind to a live ``chrys_history`` state dict.
|
|
326
|
+
|
|
327
|
+
Called after agent construction. The manager mutates this dict
|
|
328
|
+
directly — CompressibleHistoryProvider sees the changes immediately.
|
|
329
|
+
|
|
330
|
+
The caller stamps restored history before binding it. Binding itself
|
|
331
|
+
only replaces the live state reference.
|
|
332
|
+
"""
|
|
333
|
+
self._state = state
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def state(self) -> dict[str, Any]:
|
|
337
|
+
"""The full ``chrys_history`` state dict.
|
|
338
|
+
|
|
339
|
+
Raises ``RuntimeError`` if not yet bound.
|
|
340
|
+
"""
|
|
341
|
+
if self._state is None:
|
|
342
|
+
raise RuntimeError("SessionHistoryManager not bound — call bind() first")
|
|
343
|
+
return self._state
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def messages(self) -> list:
|
|
347
|
+
"""The active message list (may be empty)."""
|
|
348
|
+
return self.state.get("messages", [])
|
|
349
|
+
|
|
350
|
+
def get_deep_copy(self) -> dict[str, Any] | None:
|
|
351
|
+
"""Return a deep copy of the current state, or ``None`` if empty."""
|
|
352
|
+
if self._state is None:
|
|
353
|
+
return None
|
|
354
|
+
raw = self._state
|
|
355
|
+
return copy.deepcopy(raw) if raw else None
|
|
356
|
+
|
|
357
|
+
# ------------------------------------------------------------------
|
|
358
|
+
# Marker operations
|
|
359
|
+
# ------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def insert_turn_marker(self) -> None:
|
|
362
|
+
"""Insert a turn marker at the end of history."""
|
|
363
|
+
state = self._state
|
|
364
|
+
if state is None or not state.get("messages"):
|
|
365
|
+
return
|
|
366
|
+
from chrys.service.context.providers.history import CompressibleHistoryProvider
|
|
367
|
+
|
|
368
|
+
turn_counter = state.get("turn_counter", 0) + 1
|
|
369
|
+
CompressibleHistoryProvider.insert_marker(state, turn_counter)
|
|
370
|
+
|
|
371
|
+
def insert_interrupted_marker(
|
|
372
|
+
self,
|
|
373
|
+
reason: str | None = None,
|
|
374
|
+
source: str = "user",
|
|
375
|
+
status_code: str | None = None,
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Insert an interruption marker so it replays on session restore."""
|
|
378
|
+
if reason is None:
|
|
379
|
+
reason = format_message(EXECUTION_INTERRUPTED_MESSAGE.bind())
|
|
380
|
+
status_code = HistoryMarkerKind.STATUS_EXECUTION_INTERRUPTED
|
|
381
|
+
state = self._state
|
|
382
|
+
if state is None:
|
|
383
|
+
return
|
|
384
|
+
messages = state.get("messages")
|
|
385
|
+
if not isinstance(messages, list):
|
|
386
|
+
return
|
|
387
|
+
marker = Message("assistant", [reason])
|
|
388
|
+
marker.additional_properties[HistoryMarkerKind.KEY] = HistoryMarkerKind.INTERRUPTED
|
|
389
|
+
marker.additional_properties["_interrupted_by"] = source
|
|
390
|
+
ensure_analytics_item_id(marker.additional_properties)
|
|
391
|
+
if status_code is not None:
|
|
392
|
+
marker.additional_properties[HistoryMarkerKind.STATUS_CODE_KEY] = status_code
|
|
393
|
+
insert_at = len(messages)
|
|
394
|
+
while insert_at > 0:
|
|
395
|
+
kind = messages[insert_at - 1].additional_properties.get(HistoryMarkerKind.KEY)
|
|
396
|
+
if kind != HistoryMarkerKind.TURN:
|
|
397
|
+
break
|
|
398
|
+
insert_at -= 1
|
|
399
|
+
messages.insert(insert_at, marker)
|
|
400
|
+
|
|
401
|
+
def trailing_status_marker(self) -> tuple[str, str] | None:
|
|
402
|
+
"""Return the trailing status marker ``(kind, source)`` if present.
|
|
403
|
+
|
|
404
|
+
Used to detect restored sessions that ended with a failure,
|
|
405
|
+
where the FSM has been reset to IDLE but the history still
|
|
406
|
+
carries the error state. ``awaiting_sub_agents`` counts as an
|
|
407
|
+
error marker too: a session that reloaded while sub-agents were
|
|
408
|
+
paused should be treated the same as an interrupted run for the
|
|
409
|
+
purposes of the user's next message.
|
|
410
|
+
|
|
411
|
+
Structural turn markers at the tail are skipped. ``source`` is the
|
|
412
|
+
marker's ``_interrupted_by`` value when present; non-interrupted
|
|
413
|
+
status markers return an empty source.
|
|
414
|
+
"""
|
|
415
|
+
state = self._state
|
|
416
|
+
if state is None:
|
|
417
|
+
return None
|
|
418
|
+
messages = state.get("messages")
|
|
419
|
+
if not isinstance(messages, list) or not messages:
|
|
420
|
+
return None
|
|
421
|
+
# SessionHistoryManager is the sole writer and stores kernel Messages.
|
|
422
|
+
typed_messages = cast("list[Message]", messages)
|
|
423
|
+
for m in reversed(typed_messages):
|
|
424
|
+
chrys_kind = m.additional_properties.get(HistoryMarkerKind.KEY)
|
|
425
|
+
if chrys_kind == HistoryMarkerKind.TURN:
|
|
426
|
+
continue
|
|
427
|
+
if chrys_kind not in HistoryMarkerKind.STATUS_MARKERS:
|
|
428
|
+
return None
|
|
429
|
+
source = m.additional_properties.get("_interrupted_by", "")
|
|
430
|
+
return chrys_kind, source if isinstance(source, str) else ""
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
def has_trailing_error_markers(self) -> bool:
|
|
434
|
+
"""Check if history ends with error/interrupted markers."""
|
|
435
|
+
return self.trailing_status_marker() is not None
|
|
436
|
+
|
|
437
|
+
def remove_trailing_markers(self) -> None:
|
|
438
|
+
"""Remove interrupted/error/awaiting markers and turn markers from end of history.
|
|
439
|
+
|
|
440
|
+
Every trailing ``turn`` marker popped also rolls
|
|
441
|
+
``state["turn_counter"]`` back by one. Without that, a
|
|
442
|
+
subsequent :meth:`insert_turn_marker` (which computes the next
|
|
443
|
+
index as ``turn_counter + 1``) would advance past the slot we
|
|
444
|
+
just freed, desynchronising ``turn_counter`` from the number of
|
|
445
|
+
markers actually present in history. That desync surfaces most
|
|
446
|
+
visibly in the retry flow: ``_on_user_retry`` calls this method
|
|
447
|
+
to drop the failed turn's marker before ``_post_run`` re-inserts
|
|
448
|
+
one on successful resume, and before the fix the restored-session
|
|
449
|
+
counter would read ``N + 1`` for an ``N``-turn conversation
|
|
450
|
+
(breaking the rollback picker's "Turn N (Current)" sentinel).
|
|
451
|
+
Turn markers are inserted sequentially, so decrementing per
|
|
452
|
+
removed marker keeps ``turn_counter`` aligned with ``max(_turn)``
|
|
453
|
+
of the surviving markers.
|
|
454
|
+
"""
|
|
455
|
+
state = self._state
|
|
456
|
+
if state is None:
|
|
457
|
+
return
|
|
458
|
+
messages = state.get("messages")
|
|
459
|
+
if not isinstance(messages, list):
|
|
460
|
+
return
|
|
461
|
+
# SessionHistoryManager is the sole writer and stores kernel Messages.
|
|
462
|
+
typed_messages = cast("list[Message]", messages)
|
|
463
|
+
while typed_messages:
|
|
464
|
+
chrys_kind = typed_messages[-1].additional_properties.get(HistoryMarkerKind.KEY)
|
|
465
|
+
if chrys_kind in HistoryMarkerKind.SESSION_COUNT_EXCLUDED:
|
|
466
|
+
if chrys_kind == HistoryMarkerKind.TURN:
|
|
467
|
+
state["turn_counter"] = max(0, state.get("turn_counter", 0) - 1)
|
|
468
|
+
typed_messages.pop()
|
|
469
|
+
else:
|
|
470
|
+
break
|
|
471
|
+
|
|
472
|
+
# ------------------------------------------------------------------
|
|
473
|
+
# Sub-agent pause marker
|
|
474
|
+
# ------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
def upsert_awaiting_sub_agents_marker(self, invocation_ids: list[str]) -> None:
|
|
477
|
+
"""Ensure history carries an ``awaiting_sub_agents`` marker listing ``invocation_ids``.
|
|
478
|
+
|
|
479
|
+
Idempotent — if a marker already exists (as the last non-turn
|
|
480
|
+
message), its ``_invocation_ids`` list is updated in place.
|
|
481
|
+
Otherwise a fresh marker is appended. Empty ``invocation_ids``
|
|
482
|
+
removes any existing marker (use :meth:`remove_awaiting_sub_agents_marker`
|
|
483
|
+
for that explicitly — this is defensive).
|
|
484
|
+
"""
|
|
485
|
+
state = self._state
|
|
486
|
+
if state is None:
|
|
487
|
+
return
|
|
488
|
+
messages = state.get("messages")
|
|
489
|
+
if not isinstance(messages, list):
|
|
490
|
+
return
|
|
491
|
+
# SessionHistoryManager is the sole writer and stores kernel Messages.
|
|
492
|
+
typed_messages = cast("list[Message]", messages)
|
|
493
|
+
|
|
494
|
+
if not invocation_ids:
|
|
495
|
+
self.remove_awaiting_sub_agents_marker()
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
# Find an existing marker to update in place.
|
|
499
|
+
for m in reversed(typed_messages):
|
|
500
|
+
kind = m.additional_properties.get(HistoryMarkerKind.KEY)
|
|
501
|
+
if kind == HistoryMarkerKind.TURN:
|
|
502
|
+
continue
|
|
503
|
+
if kind == HistoryMarkerKind.AWAITING_SUB_AGENTS:
|
|
504
|
+
m.additional_properties["_invocation_ids"] = list(invocation_ids)
|
|
505
|
+
m.additional_properties[HistoryMarkerKind.STATUS_CODE_KEY] = (
|
|
506
|
+
HistoryMarkerKind.STATUS_AWAITING_SUB_AGENTS
|
|
507
|
+
)
|
|
508
|
+
return
|
|
509
|
+
break
|
|
510
|
+
|
|
511
|
+
marker = Message(
|
|
512
|
+
"assistant",
|
|
513
|
+
[format_message(AWAITING_SUB_AGENTS_MESSAGE.bind(count=len(invocation_ids)))],
|
|
514
|
+
)
|
|
515
|
+
marker.additional_properties[HistoryMarkerKind.KEY] = HistoryMarkerKind.AWAITING_SUB_AGENTS
|
|
516
|
+
marker.additional_properties[HistoryMarkerKind.STATUS_CODE_KEY] = HistoryMarkerKind.STATUS_AWAITING_SUB_AGENTS
|
|
517
|
+
marker.additional_properties["_invocation_ids"] = list(invocation_ids)
|
|
518
|
+
ensure_analytics_item_id(marker.additional_properties)
|
|
519
|
+
messages.append(marker)
|
|
520
|
+
|
|
521
|
+
def remove_awaiting_sub_agents_marker(self) -> None:
|
|
522
|
+
"""Strip every ``awaiting_sub_agents`` marker from history.
|
|
523
|
+
|
|
524
|
+
Called when the paused-invocation set empties (last sub-agent
|
|
525
|
+
resolved) or when the parent run transitions past AWAITING_SUB_AGENTS
|
|
526
|
+
into IDLE/INTERRUPTED/FAILED (the marker is superseded by those
|
|
527
|
+
terminal markers).
|
|
528
|
+
"""
|
|
529
|
+
state = self._state
|
|
530
|
+
if state is None:
|
|
531
|
+
return
|
|
532
|
+
messages = state.get("messages")
|
|
533
|
+
if not isinstance(messages, list):
|
|
534
|
+
return
|
|
535
|
+
state["messages"] = [
|
|
536
|
+
m
|
|
537
|
+
for m in messages
|
|
538
|
+
if m.additional_properties.get(HistoryMarkerKind.KEY) != HistoryMarkerKind.AWAITING_SUB_AGENTS
|
|
539
|
+
]
|
|
540
|
+
|
|
541
|
+
def remove_orphaned_user_message(self) -> None:
|
|
542
|
+
"""Remove a trailing user message that received no model response.
|
|
543
|
+
|
|
544
|
+
After ``remove_trailing_markers()`` strips error/turn markers, the
|
|
545
|
+
last message may be a user message with zero assistant or tool
|
|
546
|
+
responses after it. This means the model produced nothing before
|
|
547
|
+
failing (e.g. 401 auth error). Removing it keeps the persisted
|
|
548
|
+
history consistent with what the user saw during the live session
|
|
549
|
+
— the failed message disappeared when they typed a new one.
|
|
550
|
+
|
|
551
|
+
If the model DID produce output (tool calls, partial response)
|
|
552
|
+
before failing, the user message and model output are preserved.
|
|
553
|
+
"""
|
|
554
|
+
state = self._state
|
|
555
|
+
if state is None:
|
|
556
|
+
return
|
|
557
|
+
messages = state.get("messages")
|
|
558
|
+
if not isinstance(messages, list) or not messages:
|
|
559
|
+
return
|
|
560
|
+
if messages[-1].role == "user":
|
|
561
|
+
logger.debug("remove_orphaned_user_message: removing '%s'", (messages[-1].text or "")[:60])
|
|
562
|
+
messages.pop()
|
|
563
|
+
|
|
564
|
+
def remove_all_status_markers(self) -> None:
|
|
565
|
+
"""Remove ALL interrupted/error/awaiting-sub-agents markers from history.
|
|
566
|
+
|
|
567
|
+
Covers every ephemeral status marker class. ``turn`` markers are
|
|
568
|
+
structural (they drive compression boundaries) and stay put.
|
|
569
|
+
"""
|
|
570
|
+
state = self._state
|
|
571
|
+
if state is None:
|
|
572
|
+
return
|
|
573
|
+
messages = state.get("messages")
|
|
574
|
+
if not isinstance(messages, list):
|
|
575
|
+
return
|
|
576
|
+
state["messages"] = [
|
|
577
|
+
m
|
|
578
|
+
for m in messages
|
|
579
|
+
if m.additional_properties.get(HistoryMarkerKind.KEY) not in HistoryMarkerKind.STATUS_MARKERS
|
|
580
|
+
]
|
|
581
|
+
|
|
582
|
+
def remove_trailing_agent_text(self) -> None:
|
|
583
|
+
"""Remove trailing text-only assistant message on interrupt.
|
|
584
|
+
|
|
585
|
+
When the user interrupts during the LLM's final response (no tool
|
|
586
|
+
calls), the response may have already been stored by ``after_run``
|
|
587
|
+
before the interrupt flag is checked. This detects and removes
|
|
588
|
+
that leaked response.
|
|
589
|
+
"""
|
|
590
|
+
state = self._state
|
|
591
|
+
if state is None:
|
|
592
|
+
return
|
|
593
|
+
messages = state.get("messages")
|
|
594
|
+
if not isinstance(messages, list) or not messages:
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
last = messages[-1]
|
|
598
|
+
if last.role != "assistant":
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
if last.additional_properties.get(HistoryMarkerKind.KEY):
|
|
602
|
+
return
|
|
603
|
+
|
|
604
|
+
has_function_call = any(c.type == "function_call" for c in last.contents)
|
|
605
|
+
if has_function_call:
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
logger.debug("remove_trailing_agent_text: removing leaked final response")
|
|
609
|
+
messages.pop()
|
|
610
|
+
|
|
611
|
+
# ------------------------------------------------------------------
|
|
612
|
+
# Message repair
|
|
613
|
+
# ------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
def ensure_user_message(
|
|
616
|
+
self,
|
|
617
|
+
text: str,
|
|
618
|
+
created_at: datetime | str | None = None,
|
|
619
|
+
contents: list[Any] | None = None,
|
|
620
|
+
*,
|
|
621
|
+
kind: UserMessageKind = "opener",
|
|
622
|
+
item_id: str | None = None,
|
|
623
|
+
reminder_source: Mapping[str, Any] | None = None,
|
|
624
|
+
) -> None:
|
|
625
|
+
"""Ensure the user message is present in session state.
|
|
626
|
+
|
|
627
|
+
When Agent.run errors before HistoryProvider.after_run fires, the user
|
|
628
|
+
message is never stored. This adds it so ``resume()`` can find it.
|
|
629
|
+
|
|
630
|
+
With ``SystemReminderMiddleware``, session state messages are always
|
|
631
|
+
clean (no ``<system-reminder>`` tags), so plain text comparison is
|
|
632
|
+
sufficient for dedup. When the caller supplies multimodal contents,
|
|
633
|
+
preserve them so retry/follow-up validation can still tell that the
|
|
634
|
+
turn included image bytes.
|
|
635
|
+
|
|
636
|
+
Dedup is scoped to the **current turn region** — messages after
|
|
637
|
+
the last ``_chrys_kind='turn'`` marker. Without scoping, a
|
|
638
|
+
same-text user message from an earlier turn (or an injection
|
|
639
|
+
anchored into one) would make the fallback skip the append and
|
|
640
|
+
lose this turn's user message on interrupt-before-persist.
|
|
641
|
+
Real-world repro: user typed "what time is it?" as an injection
|
|
642
|
+
during turn 1, later typed the same text again as a fresh
|
|
643
|
+
turn-3 user message and interrupted within 1 s → framework
|
|
644
|
+
hadn't stored it yet → fallback saw the earlier injection →
|
|
645
|
+
no append → saved state had interrupted + turn markers for
|
|
646
|
+
turn 3 but no user message, so reload dropped the prompt.
|
|
647
|
+
See ``test_ensure_user_message_appends_when_text_matches_earlier_turn``.
|
|
648
|
+
|
|
649
|
+
Dedup is also **kind-aware**: a candidate matches only when text AND
|
|
650
|
+
*kind* agree (``user_text_matches``) — retry guidance (``"injected"``)
|
|
651
|
+
worded identically to the turn opener must append a second, flagged
|
|
652
|
+
message rather than silently dedup against the opener (and vice
|
|
653
|
+
versa). The matched message's flags are never backfilled: stamping
|
|
654
|
+
the real opener ``_injected`` would stop it opening the turn.
|
|
655
|
+
Recovery never synthesizes a ``"continuation"`` kind — bare-resume
|
|
656
|
+
nudge branches clear the recovery input instead.
|
|
657
|
+
|
|
658
|
+
*reminder_source* is the properties of the copy the request sent: an
|
|
659
|
+
appended message carries its reminder record, so later requests
|
|
660
|
+
re-render the reminders it went out with. A matched message keeps
|
|
661
|
+
its own record.
|
|
662
|
+
"""
|
|
663
|
+
state = self._state
|
|
664
|
+
if state is None:
|
|
665
|
+
return
|
|
666
|
+
messages = state.setdefault("messages", [])
|
|
667
|
+
start = current_turn_start(messages)
|
|
668
|
+
for m in messages[start:]:
|
|
669
|
+
if user_text_matches(m, text, kind=kind):
|
|
670
|
+
stamp_message_created_at(m, created_at)
|
|
671
|
+
ensure_analytics_item_id(m.additional_properties, item_id=item_id)
|
|
672
|
+
if (
|
|
673
|
+
contents is not None
|
|
674
|
+
and _contents_have_image_content(contents)
|
|
675
|
+
and not _message_has_image_content(m)
|
|
676
|
+
):
|
|
677
|
+
m.contents = Message("user", contents).contents
|
|
678
|
+
return
|
|
679
|
+
msg = Message("user", contents if contents is not None else [text])
|
|
680
|
+
if kind == "injected":
|
|
681
|
+
msg.additional_properties[HistoryMarkerKind.INJECTED_KEY] = True
|
|
682
|
+
stamp_message_created_at(msg, created_at)
|
|
683
|
+
ensure_analytics_item_id(msg.additional_properties, item_id=item_id)
|
|
684
|
+
copy_reminder_record(reminder_source, msg.additional_properties)
|
|
685
|
+
messages.append(msg)
|
|
686
|
+
|
|
687
|
+
def tag_last_user_message(self, key: str, value: Any) -> None:
|
|
688
|
+
"""Set an ``additional_properties`` key on the last user message.
|
|
689
|
+
|
|
690
|
+
Deliberately the LITERAL last user message, whatever its kind
|
|
691
|
+
(§2.3): with mid-turn injections that is the last injection — the
|
|
692
|
+
message actually on the wire. Flagged legacy ``continue`` nudges
|
|
693
|
+
are the one exception: the provider filters them off the wire and
|
|
694
|
+
replay hides them before reading metadata, so a tag written there
|
|
695
|
+
would vanish from every consumer.
|
|
696
|
+
"""
|
|
697
|
+
if self._state is None:
|
|
698
|
+
return
|
|
699
|
+
for m in reversed(self._state.get("messages", [])):
|
|
700
|
+
if m.role == "user" and not is_continuation_message(m):
|
|
701
|
+
m.additional_properties[key] = value
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
def remove_continuation_message(self) -> None:
|
|
705
|
+
"""Remove the synthetic ``continue`` nudge(s) injected by ``resume()``.
|
|
706
|
+
|
|
707
|
+
Two passes over the current turn region (messages after the last
|
|
708
|
+
``_chrys_kind='turn'`` marker):
|
|
709
|
+
|
|
710
|
+
1. Remove **all** ``_continuation``-flagged user messages, with no
|
|
711
|
+
``seen_work`` precondition. The flag is authoritative — every
|
|
712
|
+
flagged message is a synthetic placeholder by construction — and
|
|
713
|
+
first-only would strand a crash-leftover nudge (a pass that
|
|
714
|
+
persisted its nudge but died before cleanup). This also covers
|
|
715
|
+
the no-prior-user-message synthesis, where nothing precedes the
|
|
716
|
+
nudge in its region.
|
|
717
|
+
2. Legacy fallback, run **unconditionally** after pass 1: the
|
|
718
|
+
``text == "continue"`` + ``seen_work`` match for pre-flag
|
|
719
|
+
histories, skipping ``_injected``-flagged messages — a user-typed
|
|
720
|
+
literal ``continue`` follow-up must never be popped in place of a
|
|
721
|
+
synthetic nudge. Gating pass 2 on "pass 1 removed nothing" would
|
|
722
|
+
strand the legacy nudge of a MIXED region (pre-upgrade unflagged
|
|
723
|
+
leftover + new flagged nudge). Always running is safe:
|
|
724
|
+
``seen_work`` requires work *before* the match, so an opener
|
|
725
|
+
whose text is literally ``continue`` is untouchable.
|
|
726
|
+
|
|
727
|
+
Scoping matters: a user may legitimately type ``"continue"``
|
|
728
|
+
as a nudge mid-turn in an earlier turn (e.g. "explain X" →
|
|
729
|
+
partial response → user types "continue" → assistant
|
|
730
|
+
finishes). A global forward scan would trip ``seen_work`` on
|
|
731
|
+
that earlier turn's assistant message and pop the earlier
|
|
732
|
+
nudge — losing it and leaving the current turn's injected
|
|
733
|
+
``"continue"`` in history forever. See
|
|
734
|
+
``test_remove_continuation_message_scopes_to_current_turn``.
|
|
735
|
+
"""
|
|
736
|
+
state = self._state
|
|
737
|
+
if state is None:
|
|
738
|
+
return
|
|
739
|
+
messages = state.get("messages")
|
|
740
|
+
if not isinstance(messages, list):
|
|
741
|
+
return
|
|
742
|
+
start = current_turn_start(messages)
|
|
743
|
+
for i in range(len(messages) - 1, start - 1, -1):
|
|
744
|
+
if is_continuation_message(messages[i]):
|
|
745
|
+
messages.pop(i)
|
|
746
|
+
seen_work = False
|
|
747
|
+
for i in range(start, len(messages)):
|
|
748
|
+
msg = messages[i]
|
|
749
|
+
if msg.role in ("assistant", "tool"):
|
|
750
|
+
seen_work = True
|
|
751
|
+
elif (
|
|
752
|
+
msg.role == "user"
|
|
753
|
+
and seen_work
|
|
754
|
+
and (msg.text or "").strip() == "continue"
|
|
755
|
+
and not msg.additional_properties.get(HistoryMarkerKind.INJECTED_KEY)
|
|
756
|
+
):
|
|
757
|
+
messages.pop(i)
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
def merge_loop_messages(self, loop_recorder: MergeableLoopRecorder, *, insert_index: int | None = None) -> None:
|
|
761
|
+
"""Recover completed tool loop iterations into session state.
|
|
762
|
+
|
|
763
|
+
When the tool loop terminates early (interrupt or error),
|
|
764
|
+
``after_run`` only stores the last iteration's messages.
|
|
765
|
+
``LoopRecorder`` snapshots the accumulated messages before each
|
|
766
|
+
LLM call. This method inserts them at the correct position.
|
|
767
|
+
|
|
768
|
+
``insert_index`` is the caller's pass-start boundary (where this
|
|
769
|
+
run's output chronologically belongs). It beats the last-user-message
|
|
770
|
+
anchor when retained work from an earlier same-turn pass follows that
|
|
771
|
+
anchor: after an empty retry the synthetic continuation was already
|
|
772
|
+
removed, so anchoring to the original user message alone would insert
|
|
773
|
+
this pass BEFORE the earlier pass's retained messages, inverting the
|
|
774
|
+
transcript. The anchor still wins when it sits later (e.g. a mid-run
|
|
775
|
+
injection user message persisted after the boundary).
|
|
776
|
+
"""
|
|
777
|
+
loop_msgs = loop_recorder.loop_messages
|
|
778
|
+
if not loop_msgs:
|
|
779
|
+
logger.debug(
|
|
780
|
+
"merge_loop_messages: no loop messages (initial_count=%s, captured=%s)",
|
|
781
|
+
loop_recorder.initial_count,
|
|
782
|
+
loop_recorder.captured_count,
|
|
783
|
+
)
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
state = self._state
|
|
787
|
+
if state is None:
|
|
788
|
+
return
|
|
789
|
+
messages = state.get("messages")
|
|
790
|
+
if not isinstance(messages, list) or not messages:
|
|
791
|
+
return
|
|
792
|
+
|
|
793
|
+
logger.debug(
|
|
794
|
+
"merge_loop_messages: %d loop messages to merge, %d existing messages",
|
|
795
|
+
len(loop_msgs),
|
|
796
|
+
len(messages),
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
# A recovery checkpoint may already have replayed a clean injection
|
|
800
|
+
# that LoopRecorder also captured for the next tool request. Drop only
|
|
801
|
+
# exact consumption-id duplicates; same-text distinct injections stay.
|
|
802
|
+
current_region = messages[current_turn_start(messages) :]
|
|
803
|
+
persisted_injection_ids = {
|
|
804
|
+
injection_id
|
|
805
|
+
for message in current_region
|
|
806
|
+
if isinstance(
|
|
807
|
+
injection_id := message.additional_properties.get(HistoryMarkerKind.INJECTION_ID_KEY),
|
|
808
|
+
str,
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
if persisted_injection_ids:
|
|
812
|
+
loop_msgs = [
|
|
813
|
+
message
|
|
814
|
+
for message in loop_msgs
|
|
815
|
+
if message.additional_properties.get(HistoryMarkerKind.INJECTION_ID_KEY) not in persisted_injection_ids
|
|
816
|
+
]
|
|
817
|
+
if not loop_msgs:
|
|
818
|
+
return
|
|
819
|
+
|
|
820
|
+
# Check if loop messages are already present (normal completion path).
|
|
821
|
+
first_loop = loop_msgs[0]
|
|
822
|
+
for m in messages:
|
|
823
|
+
if m is first_loop:
|
|
824
|
+
logger.debug("merge_loop_messages: already present (identity match), skipping")
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
# Find the last user message — loop messages go right after it.
|
|
828
|
+
user_idx = -1
|
|
829
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
830
|
+
if messages[i].role == "user":
|
|
831
|
+
user_idx = i
|
|
832
|
+
break
|
|
833
|
+
|
|
834
|
+
if user_idx < 0:
|
|
835
|
+
logger.debug("merge_loop_messages: no user message found")
|
|
836
|
+
return
|
|
837
|
+
|
|
838
|
+
insert_at = user_idx + 1
|
|
839
|
+
if insert_index is not None:
|
|
840
|
+
insert_at = max(insert_at, min(insert_index, len(messages)))
|
|
841
|
+
for j, lm in enumerate(loop_msgs):
|
|
842
|
+
messages.insert(insert_at + j, lm)
|
|
843
|
+
logger.debug(
|
|
844
|
+
"merge_loop_messages: inserted %d messages at index %d, total now %d",
|
|
845
|
+
len(loop_msgs),
|
|
846
|
+
insert_at,
|
|
847
|
+
len(messages),
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
def inject_error_results_for_sub_agents(
|
|
851
|
+
self,
|
|
852
|
+
records: list[dict[str, Any]],
|
|
853
|
+
sub_agent_tool_names: set[str],
|
|
854
|
+
) -> int:
|
|
855
|
+
"""Insert synthetic error tool-results for dangling sub-agent function_calls.
|
|
856
|
+
|
|
857
|
+
``records`` is a list of serialized paused-sub-agent dicts
|
|
858
|
+
(as produced by :meth:`SubAgentSessionArtifactService.drain_paused_records`) and
|
|
859
|
+
``sub_agent_tool_names`` is the current registry of sub-agent
|
|
860
|
+
tool names. For each assistant message with unmatched
|
|
861
|
+
sub-agent function_calls (no corresponding ``function_result``),
|
|
862
|
+
we append a tool message carrying an ``Error:`` result.
|
|
863
|
+
|
|
864
|
+
**Pairing policy** (two-pass):
|
|
865
|
+
|
|
866
|
+
1. Prefer direct pairing by ``parent_call_id`` — records that
|
|
867
|
+
carry it identify the exact framework call_id of the
|
|
868
|
+
assistant function_call that invoked the sub-agent, so we
|
|
869
|
+
can reattach each record to its own call even when the same
|
|
870
|
+
sub-agent tool was invoked concurrently.
|
|
871
|
+
2. Fall back to tool_name + appearance order for records
|
|
872
|
+
without a usable ``parent_call_id`` (older saved sessions
|
|
873
|
+
or edge cases where the field is empty).
|
|
874
|
+
|
|
875
|
+
Dangling calls that have no matching record (e.g. profile
|
|
876
|
+
removed the sub-agent) get a generic error so history stays
|
|
877
|
+
well-formed. Returns the number of error results injected.
|
|
878
|
+
"""
|
|
879
|
+
state = self._state
|
|
880
|
+
if state is None:
|
|
881
|
+
return 0
|
|
882
|
+
messages = state.get("messages")
|
|
883
|
+
if not isinstance(messages, list) or not messages:
|
|
884
|
+
return 0
|
|
885
|
+
record_tool_names: set[str] = set()
|
|
886
|
+
for rec in records:
|
|
887
|
+
name = rec.get("tool_name")
|
|
888
|
+
invocation_id = rec.get("invocation_id")
|
|
889
|
+
if isinstance(name, str) and name and isinstance(invocation_id, str) and invocation_id:
|
|
890
|
+
record_tool_names.add(name)
|
|
891
|
+
candidate_tool_names = set(sub_agent_tool_names) | record_tool_names
|
|
892
|
+
|
|
893
|
+
def _is_sub_agent_call(content: Any) -> bool:
|
|
894
|
+
# Match by current/record tool_name OR by the call's OWN persisted
|
|
895
|
+
# kind marker. A profile change can drop the tool from the registry
|
|
896
|
+
# AND drain can lose every record (over-cap/corrupt/missing), yet
|
|
897
|
+
# the persisted function_call still self-identifies as a sub-agent
|
|
898
|
+
# via ``_chrys_tool_kind``. Repair it regardless, or the parent
|
|
899
|
+
# assistant call is left dangling and the next provider request
|
|
900
|
+
# rejects the history. This is why there is no early return on an
|
|
901
|
+
# empty ``candidate_tool_names`` — kind-marked calls still qualify.
|
|
902
|
+
if content.name in candidate_tool_names:
|
|
903
|
+
return True
|
|
904
|
+
return persisted_tool_call_kind(content.additional_properties) == KIND_SUB_AGENT
|
|
905
|
+
|
|
906
|
+
# Import locally — ``Content`` is only needed here and keeping
|
|
907
|
+
# it out of the module-level import surface mirrors how other
|
|
908
|
+
# methods pull in framework-internal content helpers.
|
|
909
|
+
# Two pools so pass-1 (by parent_call_id) and pass-2 (by
|
|
910
|
+
# tool_name appearance order) don't trip each other. A record
|
|
911
|
+
# with a usable parent_call_id goes into ``by_call_id`` only;
|
|
912
|
+
# records without it fall through to the ordered pool.
|
|
913
|
+
from collections import defaultdict, deque
|
|
914
|
+
|
|
915
|
+
from chrys.kernel import Content
|
|
916
|
+
|
|
917
|
+
by_call_id: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
918
|
+
name_pool: dict[str, deque[dict[str, Any]]] = defaultdict(deque)
|
|
919
|
+
for rec in records:
|
|
920
|
+
parent_call_id = rec.get("parent_provider_call_id") or rec.get("parent_call_id") or ""
|
|
921
|
+
if parent_call_id:
|
|
922
|
+
by_call_id[parent_call_id].append(rec)
|
|
923
|
+
else:
|
|
924
|
+
name_pool[rec.get("tool_name", "")].append(rec)
|
|
925
|
+
|
|
926
|
+
# Materialize exchange boundaries and pairings once; injections are
|
|
927
|
+
# queued and inserted after the scan, so coordinates stay valid.
|
|
928
|
+
accessor = LiveAccessor()
|
|
929
|
+
exchange_list = list(iter_exchanges(messages, accessor))
|
|
930
|
+
entries_per_exchange = [
|
|
931
|
+
_repair_call_entries(pair_results(messages, exchange, accessor, _REPAIR_PAIRING_POLICY))
|
|
932
|
+
for exchange in exchange_list
|
|
933
|
+
]
|
|
934
|
+
|
|
935
|
+
# Count distinct unresolved exchanges per ORIGINAL call id, not raw
|
|
936
|
+
# call occurrences (sibling duplicates in one exchange are one
|
|
937
|
+
# dangling invocation). Reuse across separate exchanges remains
|
|
938
|
+
# ambiguous and must continue to fail closed. Unhashable originals
|
|
939
|
+
# are skipped — they can never match a record-keyed direct lookup.
|
|
940
|
+
dangling_exchanges: dict[Any, set[int]] = defaultdict(set)
|
|
941
|
+
for position, entries in enumerate(entries_per_exchange):
|
|
942
|
+
for call, key, answered in entries:
|
|
943
|
+
if answered or key is None:
|
|
944
|
+
continue
|
|
945
|
+
content = messages[call.message_index].contents[call.content_index]
|
|
946
|
+
if not _is_sub_agent_call(content) or not isinstance(content.call_id, Hashable):
|
|
947
|
+
continue
|
|
948
|
+
dangling_exchanges[content.call_id].add(position)
|
|
949
|
+
dangling_call_counts = {call_id: len(positions) for call_id, positions in dangling_exchanges.items()}
|
|
950
|
+
|
|
951
|
+
def _format_err(name: str, record: dict[str, Any] | None) -> str:
|
|
952
|
+
if record is None:
|
|
953
|
+
msg = f"Error: sub-agent '{name}' was paused when the session was saved; state discarded on reload."
|
|
954
|
+
else:
|
|
955
|
+
last_error = record.get("last_error") or "unknown"
|
|
956
|
+
msg = (
|
|
957
|
+
f"Error: sub-agent '{name}' was paused when the session was saved; "
|
|
958
|
+
f"state discarded on reload — {last_error}"
|
|
959
|
+
)
|
|
960
|
+
# This string is injected into provider-visible repaired history; a restored
|
|
961
|
+
# pending record can carry a lone surrogate (round-tripped from an owner-valid
|
|
962
|
+
# audit) that the LLM request serializer's strict encode would crash on. The
|
|
963
|
+
# file sink cannot cover a network encode, so neutralize at the interpolation.
|
|
964
|
+
return surrogate_safe_text(msg)
|
|
965
|
+
|
|
966
|
+
injections: list[tuple[int, Message]] = [] # (insert_before_index, msg)
|
|
967
|
+
injected = 0
|
|
968
|
+
for position, exchange in enumerate(exchange_list):
|
|
969
|
+
# Every synthetic result lands at the exchange's shared output
|
|
970
|
+
# boundary, after the whole consecutive assistant run. Truthy
|
|
971
|
+
# keys schedule at most once per exchange: queued injections do
|
|
972
|
+
# not enter ``messages`` until after this scan, and sibling
|
|
973
|
+
# duplicates of one id are a single dangling invocation.
|
|
974
|
+
insert_at = exchange.output_indices[0] if exchange.output_indices else exchange.response_indices[-1] + 1
|
|
975
|
+
scheduled_keys: set[object] = set()
|
|
976
|
+
for call, key, answered in entries_per_exchange[position]:
|
|
977
|
+
if answered or (key is not None and key in scheduled_keys):
|
|
978
|
+
continue
|
|
979
|
+
content = messages[call.message_index].contents[call.content_index]
|
|
980
|
+
if not _is_sub_agent_call(content):
|
|
981
|
+
continue
|
|
982
|
+
original_id = content.call_id
|
|
983
|
+
|
|
984
|
+
# Pass 1 — direct pair by parent_call_id, always through the
|
|
985
|
+
# ORIGINAL persisted id. An unhashable original is an
|
|
986
|
+
# unmatched lookup (never a normalized substitution) and
|
|
987
|
+
# falls through to the name pool exactly as an unknown id.
|
|
988
|
+
record = None
|
|
989
|
+
direct_ambiguous = False
|
|
990
|
+
direct_records = by_call_id.get(original_id) if isinstance(original_id, Hashable) else None
|
|
991
|
+
if direct_records:
|
|
992
|
+
if len(direct_records) == 1 and dangling_call_counts.get(original_id, 0) == 1:
|
|
993
|
+
record = direct_records.pop()
|
|
994
|
+
by_call_id.pop(original_id, None)
|
|
995
|
+
else:
|
|
996
|
+
direct_ambiguous = True
|
|
997
|
+
logger.warning(
|
|
998
|
+
"Sub-agent reload found ambiguous parent_call_id=%s for '%s'; "
|
|
999
|
+
"injecting generic repair and preserving pending records.",
|
|
1000
|
+
original_id,
|
|
1001
|
+
content.name,
|
|
1002
|
+
)
|
|
1003
|
+
# Pass 2 — fall back to name+order for records that
|
|
1004
|
+
# didn't carry a parent_call_id (legacy sessions).
|
|
1005
|
+
if record is None and not direct_ambiguous and name_pool.get(content.name):
|
|
1006
|
+
record = name_pool[content.name].popleft()
|
|
1007
|
+
logger.warning(
|
|
1008
|
+
"Sub-agent reload paired '%s' via tool_name+order fallback "
|
|
1009
|
+
"(call_id=%s); re-save the session to persist parent_call_id "
|
|
1010
|
+
"for precise recovery on next reload.",
|
|
1011
|
+
content.name,
|
|
1012
|
+
original_id,
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
err_text = _format_err(content.name, record)
|
|
1016
|
+
result = Content.from_function_result(call_id=original_id, result=err_text)
|
|
1017
|
+
if record is not None:
|
|
1018
|
+
record[SUB_AGENT_RESTORE_CONSUMED_KEY] = True
|
|
1019
|
+
metadata = _sub_agent_result_metadata(record)
|
|
1020
|
+
if metadata:
|
|
1021
|
+
result.additional_properties[TOOL_RESULT_METADATA_KEY] = metadata
|
|
1022
|
+
injections.append((insert_at, Message("tool", [result])))
|
|
1023
|
+
if key is not None:
|
|
1024
|
+
scheduled_keys.add(key)
|
|
1025
|
+
injected += 1
|
|
1026
|
+
|
|
1027
|
+
# Insert from the end so earlier indices stay valid. Every synthetic
|
|
1028
|
+
# result lands at the response's shared result-block boundary, after
|
|
1029
|
+
# all consecutive assistant siblings. Multiple results targeting the
|
|
1030
|
+
# same boundary retain appearance order through stable reverse
|
|
1031
|
+
# iteration and repeated insertion at that boundary.
|
|
1032
|
+
for insert_at, msg in reversed(injections):
|
|
1033
|
+
messages.insert(insert_at, msg)
|
|
1034
|
+
return injected
|
|
1035
|
+
|
|
1036
|
+
def trim_to_last_complete_tool_results(self) -> None:
|
|
1037
|
+
"""Trim messages to the last complete tool exchange on interrupt.
|
|
1038
|
+
|
|
1039
|
+
Walks backwards to the last exchange (a run of assistant siblings
|
|
1040
|
+
plus the tool output answering it) and removes function calls the
|
|
1041
|
+
exchange never answered — checking EVERY sibling of the run against
|
|
1042
|
+
the shared output block and embedded results, per trim's pairing
|
|
1043
|
+
row. Exchanges whose call siblings all pop cascade the walk into
|
|
1044
|
+
earlier history; any surviving sibling stops it.
|
|
1045
|
+
"""
|
|
1046
|
+
state = self._state
|
|
1047
|
+
if state is None:
|
|
1048
|
+
return
|
|
1049
|
+
messages = state.get("messages")
|
|
1050
|
+
if not isinstance(messages, list) or not messages:
|
|
1051
|
+
return
|
|
1052
|
+
|
|
1053
|
+
# Materialize boundaries and pairings BEFORE any mutation: the walk
|
|
1054
|
+
# only pops or edits messages at indices at-or-above everything it
|
|
1055
|
+
# still has left to visit, so earlier exchanges' coordinates stay
|
|
1056
|
+
# valid as it descends.
|
|
1057
|
+
accessor = LiveAccessor()
|
|
1058
|
+
exchange_list = list(iter_exchanges(messages, accessor))
|
|
1059
|
+
pairings = [pair_results(messages, exchange, accessor, _TRIM_PAIRING_POLICY) for exchange in exchange_list]
|
|
1060
|
+
owner_by_response_index = {
|
|
1061
|
+
index: position for position, exchange in enumerate(exchange_list) for index in exchange.response_indices
|
|
1062
|
+
}
|
|
1063
|
+
output_indices = {index for exchange in exchange_list for index in exchange.output_indices}
|
|
1064
|
+
|
|
1065
|
+
i = len(messages) - 1
|
|
1066
|
+
while i >= 0:
|
|
1067
|
+
msg = messages[i]
|
|
1068
|
+
if msg.role != "assistant":
|
|
1069
|
+
i -= 1
|
|
1070
|
+
continue
|
|
1071
|
+
|
|
1072
|
+
if HistoryMarkerKind.KEY in msg.additional_properties:
|
|
1073
|
+
# Assistant role alone does not make a message a response
|
|
1074
|
+
# sibling. In particular, the awaiting-sub-agents marker is
|
|
1075
|
+
# a deliberate shield for its dangling parent call until
|
|
1076
|
+
# reload repair can answer it.
|
|
1077
|
+
break
|
|
1078
|
+
|
|
1079
|
+
position = owner_by_response_index.get(i)
|
|
1080
|
+
if position is None:
|
|
1081
|
+
if i in output_indices:
|
|
1082
|
+
# A result-only assistant continuing an exchange's output
|
|
1083
|
+
# block; the exchange is processed at its response run.
|
|
1084
|
+
i -= 1
|
|
1085
|
+
continue
|
|
1086
|
+
if (
|
|
1087
|
+
i > 0
|
|
1088
|
+
and messages[i - 1].role == "assistant"
|
|
1089
|
+
and HistoryMarkerKind.KEY not in messages[i - 1].additional_properties
|
|
1090
|
+
):
|
|
1091
|
+
# A call-less assistant run is not a safe trim boundary
|
|
1092
|
+
# while it may still lead back to a response's calls.
|
|
1093
|
+
i -= 1
|
|
1094
|
+
continue
|
|
1095
|
+
break
|
|
1096
|
+
|
|
1097
|
+
exchange = exchange_list[position]
|
|
1098
|
+
if _trim_exchange_siblings(messages, exchange, pairings[position]):
|
|
1099
|
+
break
|
|
1100
|
+
i = exchange.response_indices[0] - 1
|
|
1101
|
+
|
|
1102
|
+
# ------------------------------------------------------------------
|
|
1103
|
+
# Metadata persistence
|
|
1104
|
+
# ------------------------------------------------------------------
|
|
1105
|
+
|
|
1106
|
+
def persist_batch_ids(self, batch_records: list[ToolBatchRecord]) -> dict[int, Message]:
|
|
1107
|
+
"""Assign ``_batch_id`` to assistant messages by record identity.
|
|
1108
|
+
|
|
1109
|
+
Matching is keyed by ``(provider_call_id, tool_name)`` — the
|
|
1110
|
+
persisted ``function_call.call_id`` IS the raw provider id — never
|
|
1111
|
+
by position: positional zips misattribute whenever a pre-run
|
|
1112
|
+
unbatched straggler, an interrupted pass, or mid-pass compression
|
|
1113
|
+
shifts the pairing. Deliberately NO ``tool_order`` and NO chrys-id
|
|
1114
|
+
fallback (an order-only match hits a stale slot *uniquely*, and the
|
|
1115
|
+
chrys id may be a fresh uuid that matches nothing); a record
|
|
1116
|
+
without ``provider_call_id`` is dropped with a debug log — for
|
|
1117
|
+
batch attribution, loss beats misattribution.
|
|
1118
|
+
|
|
1119
|
+
Duplicate keys are matched **bucketed, never per record**:
|
|
1120
|
+
sequential-id providers reset ids per response, so the same key
|
|
1121
|
+
legitimately repeats within one pass. Records group per key in
|
|
1122
|
+
drain (chronological) order and un-batched slots per key in message
|
|
1123
|
+
order; for N records only the NEWEST N slots stay eligible (surplus
|
|
1124
|
+
oldest slots are stale stragglers and stay un-batched), then
|
|
1125
|
+
records assign chronologically within the bucket. Slots are
|
|
1126
|
+
(message, function_call content) pairs; the first record assigned
|
|
1127
|
+
to a message writes ``_batch_id``, later records verify the id
|
|
1128
|
+
matches and no-op (debug log on mismatch, never overwrite). The
|
|
1129
|
+
scan bound is the current turn region — identity matching is what
|
|
1130
|
+
makes the wide bound safe; a strict numeric floor would orphan
|
|
1131
|
+
same-pass pre-compression batches.
|
|
1132
|
+
|
|
1133
|
+
Returns ``{batch_id: anchor message}`` — the first message of each
|
|
1134
|
+
batch in record (chronological) order — for
|
|
1135
|
+
:meth:`persist_intermediate_texts`. Must be called before it.
|
|
1136
|
+
"""
|
|
1137
|
+
anchors: dict[int, Message] = {}
|
|
1138
|
+
if not batch_records:
|
|
1139
|
+
return anchors
|
|
1140
|
+
state = self._state
|
|
1141
|
+
if state is None:
|
|
1142
|
+
return anchors
|
|
1143
|
+
messages = state.get("messages")
|
|
1144
|
+
if not isinstance(messages, list):
|
|
1145
|
+
return anchors
|
|
1146
|
+
|
|
1147
|
+
record_buckets: dict[tuple[str, str], list[tuple[int, ToolBatchRecord]]] = {}
|
|
1148
|
+
for drain_idx, record in enumerate(batch_records):
|
|
1149
|
+
if not record.provider_call_id:
|
|
1150
|
+
logger.debug(
|
|
1151
|
+
"persist_batch_ids: dropping record without provider_call_id (tool=%s order=%s batch=%s)",
|
|
1152
|
+
record.tool_name,
|
|
1153
|
+
record.tool_order,
|
|
1154
|
+
record.batch_id,
|
|
1155
|
+
)
|
|
1156
|
+
continue
|
|
1157
|
+
record_buckets.setdefault((record.provider_call_id, record.tool_name), []).append((drain_idx, record))
|
|
1158
|
+
if not record_buckets:
|
|
1159
|
+
return anchors
|
|
1160
|
+
|
|
1161
|
+
# Slots: (provider_call_id, tool_name) → messages in order, from
|
|
1162
|
+
# un-batched assistant function_call messages in the current turn
|
|
1163
|
+
# region. The un-batched filter stays message-level.
|
|
1164
|
+
slot_buckets: dict[tuple[str, str], list[Message]] = {}
|
|
1165
|
+
start = current_turn_start(messages)
|
|
1166
|
+
for msg in messages[start:]:
|
|
1167
|
+
if msg.role != "assistant" or "_batch_id" in msg.additional_properties:
|
|
1168
|
+
continue
|
|
1169
|
+
for c in msg.contents:
|
|
1170
|
+
if c.type != "function_call" or c.informational_only or not c.call_id:
|
|
1171
|
+
continue
|
|
1172
|
+
slot_buckets.setdefault((c.call_id, c.name or ""), []).append(msg)
|
|
1173
|
+
|
|
1174
|
+
assignments: list[tuple[int, int, Message]] = []
|
|
1175
|
+
for key, keyed_records in record_buckets.items():
|
|
1176
|
+
slots = slot_buckets.get(key, [])
|
|
1177
|
+
if len(slots) < len(keyed_records):
|
|
1178
|
+
logger.debug(
|
|
1179
|
+
"persist_batch_ids: %d record(s) for key %s found no slot",
|
|
1180
|
+
len(keyed_records) - len(slots),
|
|
1181
|
+
key,
|
|
1182
|
+
)
|
|
1183
|
+
eligible = slots[-len(keyed_records) :]
|
|
1184
|
+
# strict=False: a slot shortfall is tolerated (logged above) —
|
|
1185
|
+
# surplus records simply stay un-stamped.
|
|
1186
|
+
for (drain_idx, record), msg in zip(keyed_records, eligible, strict=False):
|
|
1187
|
+
assignments.append((drain_idx, record.batch_id, msg))
|
|
1188
|
+
|
|
1189
|
+
# Stamp in global chronological (drain) order so the anchor of a
|
|
1190
|
+
# multi-message batch is the batch's FIRST message.
|
|
1191
|
+
for _drain_idx, batch_id, msg in sorted(assignments, key=lambda a: a[0]):
|
|
1192
|
+
existing = msg.additional_properties.get("_batch_id")
|
|
1193
|
+
if existing is None:
|
|
1194
|
+
msg.additional_properties["_batch_id"] = batch_id
|
|
1195
|
+
elif existing != batch_id:
|
|
1196
|
+
logger.debug(
|
|
1197
|
+
"persist_batch_ids: batch id mismatch on message (existing=%s record=%s) — not overwritten",
|
|
1198
|
+
existing,
|
|
1199
|
+
batch_id,
|
|
1200
|
+
)
|
|
1201
|
+
continue
|
|
1202
|
+
anchors.setdefault(batch_id, msg)
|
|
1203
|
+
return anchors
|
|
1204
|
+
|
|
1205
|
+
def persist_intermediate_texts(self, texts: dict[int, str], batch_anchors: dict[int, Message]) -> None:
|
|
1206
|
+
"""Embed captured intermediate texts into their batches' anchor messages.
|
|
1207
|
+
|
|
1208
|
+
``texts`` maps ``batch_id → text`` recorded at capture time;
|
|
1209
|
+
``batch_anchors`` is :meth:`persist_batch_ids`'s return value (which
|
|
1210
|
+
**must** run first). Assignment is a pure ``batch_id`` lookup — the
|
|
1211
|
+
old last-user-message anchor and positional pairing are gone. A
|
|
1212
|
+
text whose batch matched no message this pass (e.g. every call in
|
|
1213
|
+
the batch failed argument validation and produced no record) is
|
|
1214
|
+
DROPPED with a debug log, never positionally re-attached: loss
|
|
1215
|
+
beats misattribution. Anchors also keep a fresh text off any
|
|
1216
|
+
stale same-id ``_batch_id`` from a restored session's earlier
|
|
1217
|
+
process, whose counter restarted.
|
|
1218
|
+
"""
|
|
1219
|
+
if not texts:
|
|
1220
|
+
return
|
|
1221
|
+
for batch_id, text in sorted(texts.items()):
|
|
1222
|
+
if not text:
|
|
1223
|
+
continue
|
|
1224
|
+
msg = batch_anchors.get(batch_id)
|
|
1225
|
+
if msg is None:
|
|
1226
|
+
logger.debug(
|
|
1227
|
+
"persist_intermediate_texts: dropping text for unmatched batch %s (%d chars)",
|
|
1228
|
+
batch_id,
|
|
1229
|
+
len(text),
|
|
1230
|
+
)
|
|
1231
|
+
continue
|
|
1232
|
+
# Match intermediate_text_signal(), which concatenates text
|
|
1233
|
+
# parts without separators before deciding whether a sidecar is
|
|
1234
|
+
# needed. Replay also recognizes this form for compatibility.
|
|
1235
|
+
existing_text = "".join((c.text or "") for c in msg.contents if c.type == "text")
|
|
1236
|
+
if existing_text == text:
|
|
1237
|
+
continue
|
|
1238
|
+
msg.additional_properties["_intermediate_text"] = text
|
|
1239
|
+
|
|
1240
|
+
def persist_approval_decisions(
|
|
1241
|
+
self,
|
|
1242
|
+
decisions: list[dict[str, str]],
|
|
1243
|
+
*,
|
|
1244
|
+
start_index: int | None = None,
|
|
1245
|
+
) -> None:
|
|
1246
|
+
"""Tag assistant messages with approval decision info.
|
|
1247
|
+
|
|
1248
|
+
Matches decisions to function-call contents by the kernel-stamped
|
|
1249
|
+
invocation ordinal first, then by persisted call_id, then by legacy
|
|
1250
|
+
tool-name order. Order matching is a pure stamp read — this method
|
|
1251
|
+
never counts function calls itself, so calls that failed pre-pipeline
|
|
1252
|
+
validation cannot shift later matches. Ordinals restart at zero for
|
|
1253
|
+
every kernel run and one logical turn can hold several runs (a
|
|
1254
|
+
retried pass after a failure, interrupt/resume), so the scan region
|
|
1255
|
+
can retain same-ordinal calls from an earlier pass — typically a
|
|
1256
|
+
pre-validation failure that never reached the approval middleware.
|
|
1257
|
+
Calls whose immediate results carry a pre-pipeline ``tool_error_kind``
|
|
1258
|
+
are excluded from every stage (they can never hold a decision);
|
|
1259
|
+
beyond that, decisions always describe the most recent pass, so each
|
|
1260
|
+
order decision attaches to the LAST stamp match, never the first. An
|
|
1261
|
+
unstamped content (legacy
|
|
1262
|
+
history) skips the order stage and falls through to the id/name
|
|
1263
|
+
stages. When ``start_index`` is supplied, matching is scoped to the
|
|
1264
|
+
current turn so older same-name tool calls cannot consume new
|
|
1265
|
+
approval decisions.
|
|
1266
|
+
"""
|
|
1267
|
+
if not decisions:
|
|
1268
|
+
return
|
|
1269
|
+
state = self._state
|
|
1270
|
+
if state is None:
|
|
1271
|
+
return
|
|
1272
|
+
messages = state.get("messages")
|
|
1273
|
+
if not isinstance(messages, list):
|
|
1274
|
+
return
|
|
1275
|
+
|
|
1276
|
+
start_index = _effective_metadata_start_index(messages, start_index)
|
|
1277
|
+
|
|
1278
|
+
usable_decisions = [decision for decision in decisions if decision.get("status") != "approval_pending"]
|
|
1279
|
+
if not usable_decisions:
|
|
1280
|
+
return
|
|
1281
|
+
|
|
1282
|
+
# An unhashable original id stays OUT of the scoped set (a string
|
|
1283
|
+
# decision id could never equal it; including it would only crash
|
|
1284
|
+
# the build) — its call still matches by stamp or name below.
|
|
1285
|
+
scoped_call_ids = {
|
|
1286
|
+
c.call_id
|
|
1287
|
+
for msg in messages[start_index:]
|
|
1288
|
+
if msg.role == "assistant"
|
|
1289
|
+
for c in msg.contents
|
|
1290
|
+
if c.type == "function_call" and not c.informational_only and c.call_id and isinstance(c.call_id, Hashable)
|
|
1291
|
+
}
|
|
1292
|
+
used_decision_indexes: set[int] = set()
|
|
1293
|
+
fallback_cursor = 0
|
|
1294
|
+
|
|
1295
|
+
def _decision_order(decision: dict[str, str]) -> int | None:
|
|
1296
|
+
raw = decision.get("tool_order")
|
|
1297
|
+
if raw is None:
|
|
1298
|
+
return None
|
|
1299
|
+
try:
|
|
1300
|
+
return int(raw)
|
|
1301
|
+
except ValueError:
|
|
1302
|
+
return None
|
|
1303
|
+
|
|
1304
|
+
def _pop_by_call_id(call_id: str, tool_name: str) -> dict[str, str] | None:
|
|
1305
|
+
if not call_id:
|
|
1306
|
+
return None
|
|
1307
|
+
for idx, decision in enumerate(usable_decisions):
|
|
1308
|
+
if idx in used_decision_indexes:
|
|
1309
|
+
continue
|
|
1310
|
+
if decision.get("call_id") == call_id and decision.get("tool_name") == tool_name:
|
|
1311
|
+
used_decision_indexes.add(idx)
|
|
1312
|
+
return decision
|
|
1313
|
+
return None
|
|
1314
|
+
|
|
1315
|
+
def _fallback_eligible(decision: dict[str, str]) -> bool:
|
|
1316
|
+
if _decision_order(decision) is not None:
|
|
1317
|
+
return False
|
|
1318
|
+
call_id = decision.get("call_id", "")
|
|
1319
|
+
return not call_id or call_id not in scoped_call_ids
|
|
1320
|
+
|
|
1321
|
+
def _pop_fallback(tool_name: str) -> dict[str, str] | None:
|
|
1322
|
+
nonlocal fallback_cursor
|
|
1323
|
+
while fallback_cursor < len(usable_decisions):
|
|
1324
|
+
idx = fallback_cursor
|
|
1325
|
+
decision = usable_decisions[idx]
|
|
1326
|
+
if idx in used_decision_indexes or not _fallback_eligible(decision):
|
|
1327
|
+
fallback_cursor += 1
|
|
1328
|
+
continue
|
|
1329
|
+
if decision.get("tool_name") != tool_name:
|
|
1330
|
+
return None
|
|
1331
|
+
used_decision_indexes.add(idx)
|
|
1332
|
+
fallback_cursor += 1
|
|
1333
|
+
return decision
|
|
1334
|
+
return None
|
|
1335
|
+
|
|
1336
|
+
def _approval_for(content: Content, decision: dict[str, str]) -> dict[str, str]:
|
|
1337
|
+
approval: dict[str, str] = {
|
|
1338
|
+
"tool_name": decision.get("tool_name", ""),
|
|
1339
|
+
"status": decision.get("status", ""),
|
|
1340
|
+
"request_id": decision.get("request_id", ""),
|
|
1341
|
+
}
|
|
1342
|
+
if decision.get("call_id") and decision["call_id"] == (content.call_id or ""):
|
|
1343
|
+
approval["call_id"] = decision["call_id"]
|
|
1344
|
+
if decision.get("reason"):
|
|
1345
|
+
approval["reason"] = decision["reason"]
|
|
1346
|
+
if decision.get("grant_ids"):
|
|
1347
|
+
approval["grant_ids"] = decision["grant_ids"]
|
|
1348
|
+
return approval
|
|
1349
|
+
|
|
1350
|
+
def _apply_modified_args(content: Content, decision: dict[str, str]) -> bool:
|
|
1351
|
+
raw = decision.get("modified_args")
|
|
1352
|
+
if not raw:
|
|
1353
|
+
return False
|
|
1354
|
+
try:
|
|
1355
|
+
parsed = json.loads(raw)
|
|
1356
|
+
except json.JSONDecodeError:
|
|
1357
|
+
logger.debug("persist_approval_decisions: invalid modified_args JSON for %s", content.call_id)
|
|
1358
|
+
return False
|
|
1359
|
+
if not isinstance(parsed, dict):
|
|
1360
|
+
return False
|
|
1361
|
+
content.arguments = parsed
|
|
1362
|
+
return True
|
|
1363
|
+
|
|
1364
|
+
# Calls answered by a pre-pipeline validation failure (unparseable or
|
|
1365
|
+
# invalid arguments, unknown tool) never reached the approval
|
|
1366
|
+
# middleware, so they can never legitimately hold a decision —
|
|
1367
|
+
# exclude them from every matching stage, independent of message
|
|
1368
|
+
# order. The evidence is exchange-scoped (call ids are not globally
|
|
1369
|
+
# unique) and read from the ELIGIBLE pairing view, so embedded
|
|
1370
|
+
# failure results count while a marker- or foreign-role-carried
|
|
1371
|
+
# result never does.
|
|
1372
|
+
never_approvable_ids: set[int] = set()
|
|
1373
|
+
accessor = LiveAccessor()
|
|
1374
|
+
owner_by_response_index: dict[int, int] = {}
|
|
1375
|
+
exchange_exclusions: list[tuple[set[PairingKey], bool]] = []
|
|
1376
|
+
for position, exchange in enumerate(iter_exchanges(messages, accessor)):
|
|
1377
|
+
exchange_exclusions.append(_approval_exclusions(messages, exchange, accessor))
|
|
1378
|
+
for index in exchange.response_indices:
|
|
1379
|
+
owner_by_response_index[index] = position
|
|
1380
|
+
|
|
1381
|
+
# Stage 1 — kernel-stamped ordinal matching. Attach each order
|
|
1382
|
+
# decision to the LAST eligible stamp match in the region, never the
|
|
1383
|
+
# first: an earlier same-turn pass can leave behind a same-ordinal
|
|
1384
|
+
# call of the same name (ordinals restart per kernel run, and a
|
|
1385
|
+
# retried or resumed turn retains the failed pass's completed
|
|
1386
|
+
# messages), and decisions always belong to the most recent pass.
|
|
1387
|
+
# Decision call ids cannot disambiguate here — they carry the
|
|
1388
|
+
# chrys-minted id while contents keep the provider wire id, two
|
|
1389
|
+
# namespaces that never compare equal.
|
|
1390
|
+
stamped_candidates: list[tuple[Message, Content, int]] = []
|
|
1391
|
+
for msg_idx in range(start_index, len(messages)):
|
|
1392
|
+
msg = messages[msg_idx]
|
|
1393
|
+
if msg.role != "assistant":
|
|
1394
|
+
continue
|
|
1395
|
+
position = owner_by_response_index.get(msg_idx)
|
|
1396
|
+
if position is not None:
|
|
1397
|
+
excluded_keys, falsy_failure = exchange_exclusions[position]
|
|
1398
|
+
else:
|
|
1399
|
+
excluded_keys, falsy_failure = set(), False
|
|
1400
|
+
for c in msg.contents:
|
|
1401
|
+
if c.type != "function_call" or c.informational_only:
|
|
1402
|
+
continue
|
|
1403
|
+
if c.call_id is None:
|
|
1404
|
+
# The kernel raises on a None call id before the
|
|
1405
|
+
# middleware context is created, so such a call can never
|
|
1406
|
+
# hold a decision. An EMPTY id is different: provider
|
|
1407
|
+
# adapters mint "" for absent wire ids and those calls do
|
|
1408
|
+
# reach approval — they stay eligible unless their block
|
|
1409
|
+
# holds an unpairable id-less failure (below).
|
|
1410
|
+
never_approvable_ids.add(id(c))
|
|
1411
|
+
continue
|
|
1412
|
+
raw_id = c.call_id
|
|
1413
|
+
normalized_id = raw_id if isinstance(raw_id, str) else (str(raw_id) if raw_id else "")
|
|
1414
|
+
if ("call_id", normalized_id) in excluded_keys or (falsy_failure and not normalized_id):
|
|
1415
|
+
never_approvable_ids.add(id(c))
|
|
1416
|
+
continue
|
|
1417
|
+
if "_approval" not in c.additional_properties:
|
|
1418
|
+
stamped = read_tool_invocation_order(c.additional_properties)
|
|
1419
|
+
if stamped is not None:
|
|
1420
|
+
stamped_candidates.append((msg, c, stamped))
|
|
1421
|
+
|
|
1422
|
+
for idx, order_decision in enumerate(usable_decisions):
|
|
1423
|
+
order = _decision_order(order_decision)
|
|
1424
|
+
if order is None:
|
|
1425
|
+
continue
|
|
1426
|
+
for msg, content, stamped in reversed(stamped_candidates):
|
|
1427
|
+
if (
|
|
1428
|
+
stamped == order
|
|
1429
|
+
and (content.name or "") == order_decision.get("tool_name")
|
|
1430
|
+
and "_approval" not in content.additional_properties
|
|
1431
|
+
):
|
|
1432
|
+
content.additional_properties["_approval"] = _approval_for(content, order_decision)
|
|
1433
|
+
_apply_modified_args(content, order_decision)
|
|
1434
|
+
_sync_message_approval_from_contents(msg)
|
|
1435
|
+
used_decision_indexes.add(idx)
|
|
1436
|
+
break
|
|
1437
|
+
|
|
1438
|
+
for msg in messages[start_index:]:
|
|
1439
|
+
if len(used_decision_indexes) == len(usable_decisions):
|
|
1440
|
+
break
|
|
1441
|
+
if msg.role != "assistant":
|
|
1442
|
+
continue
|
|
1443
|
+
|
|
1444
|
+
message_changed = False
|
|
1445
|
+
for c in msg.contents:
|
|
1446
|
+
if c.type != "function_call" or c.informational_only or id(c) in never_approvable_ids:
|
|
1447
|
+
continue
|
|
1448
|
+
if "_approval" not in c.additional_properties:
|
|
1449
|
+
decision = _pop_by_call_id(c.call_id or "", c.name or "")
|
|
1450
|
+
if decision is None:
|
|
1451
|
+
decision = _pop_fallback(c.name or "")
|
|
1452
|
+
if decision is not None:
|
|
1453
|
+
c.additional_properties["_approval"] = _approval_for(c, decision)
|
|
1454
|
+
_apply_modified_args(c, decision)
|
|
1455
|
+
message_changed = True
|
|
1456
|
+
if message_changed:
|
|
1457
|
+
# Preserve the legacy message-level field for existing
|
|
1458
|
+
# replay/read paths. Multi-call messages keep exact per-call
|
|
1459
|
+
# approvals on the contents above.
|
|
1460
|
+
_sync_message_approval_from_contents(msg)
|
|
1461
|
+
|
|
1462
|
+
def backfill_missing_created_at(
|
|
1463
|
+
self,
|
|
1464
|
+
created_at: datetime | str | None = None,
|
|
1465
|
+
*,
|
|
1466
|
+
start_index: int | None = None,
|
|
1467
|
+
) -> int:
|
|
1468
|
+
"""Stamp missing timestamp metadata on current-run edge-case messages."""
|
|
1469
|
+
state = self._state
|
|
1470
|
+
if state is None:
|
|
1471
|
+
return 0
|
|
1472
|
+
messages = state.get("messages")
|
|
1473
|
+
if not isinstance(messages, list):
|
|
1474
|
+
return 0
|
|
1475
|
+
start_index = len(messages) if start_index is None else _effective_metadata_start_index(messages, start_index)
|
|
1476
|
+
timestamp = normalize_created_at(created_at)
|
|
1477
|
+
count = 0
|
|
1478
|
+
for msg in messages[start_index:]:
|
|
1479
|
+
if stamp_message_created_at(msg, timestamp):
|
|
1480
|
+
count += 1
|
|
1481
|
+
return count
|
|
1482
|
+
|
|
1483
|
+
def persist_consumed_injections(self, injections: list[ConsumedInjection]) -> None:
|
|
1484
|
+
"""Insert consumed injection messages into session history.
|
|
1485
|
+
|
|
1486
|
+
Each consumed injection records a text, timestamp, and ``anchor``
|
|
1487
|
+
is an :class:`InjectionAnchor` captured by :class:`InjectionMiddleware`
|
|
1488
|
+
at consumption time — describing the message the injection should
|
|
1489
|
+
land *after*.
|
|
1490
|
+
|
|
1491
|
+
:meth:`InjectionAnchor.find_in` tries exact identity
|
|
1492
|
+
(``id(contents)``) and ``message_id`` first, then falls back to
|
|
1493
|
+
structural ``role`` + ``call_ids`` matching when message assembly
|
|
1494
|
+
has replaced the captured objects. If no key
|
|
1495
|
+
matches, fall back to appending at the end.
|
|
1496
|
+
"""
|
|
1497
|
+
if not injections:
|
|
1498
|
+
return
|
|
1499
|
+
state = self._state
|
|
1500
|
+
if state is None:
|
|
1501
|
+
return
|
|
1502
|
+
messages = state.get("messages")
|
|
1503
|
+
if not isinstance(messages, list):
|
|
1504
|
+
return
|
|
1505
|
+
current_region = messages[current_turn_start(messages) :]
|
|
1506
|
+
persisted_by_id = {
|
|
1507
|
+
consumption_id: message
|
|
1508
|
+
for message in current_region
|
|
1509
|
+
if isinstance(
|
|
1510
|
+
consumption_id := message.additional_properties.get(HistoryMarkerKind.INJECTION_ID_KEY),
|
|
1511
|
+
str,
|
|
1512
|
+
)
|
|
1513
|
+
}
|
|
1514
|
+
missing: list[ConsumedInjection] = []
|
|
1515
|
+
for injection in injections:
|
|
1516
|
+
existing = persisted_by_id.get(injection.consumption_id) if injection.consumption_id is not None else None
|
|
1517
|
+
if existing is None:
|
|
1518
|
+
missing.append(injection)
|
|
1519
|
+
else:
|
|
1520
|
+
_stamp_consumption(existing, injection)
|
|
1521
|
+
_insert_consumed_injection_messages(messages, missing)
|
|
1522
|
+
|
|
1523
|
+
def replay_consumed_injections(self, injections: list[ConsumedInjection]) -> None:
|
|
1524
|
+
"""Re-insert consumed injections missing from history (crash-recovery).
|
|
1525
|
+
|
|
1526
|
+
Consumed injections normally reach history only at finalization
|
|
1527
|
+
(:meth:`persist_consumed_injections`); a recovery checkpoint built
|
|
1528
|
+
mid-run must replay them itself or a hard crash loses the injected
|
|
1529
|
+
user message entirely. Dedup against copies already present in the
|
|
1530
|
+
current turn region is IDENTITY-first: an injection is a duplicate
|
|
1531
|
+
only when a region message carries its ``_injection_id`` stamp —
|
|
1532
|
+
text matching cannot tell a persisted copy of *this* consumption
|
|
1533
|
+
from a distinct earlier injection that happens to share the text
|
|
1534
|
+
(e.g. a resumed turn whose prior run persisted a same-text note),
|
|
1535
|
+
and dropping the new one loses user input. Kind-aware text matching
|
|
1536
|
+
(``user_text_matches`` with ``kind="injected"``) remains only as the
|
|
1537
|
+
fallback for id-less legacy entries. The survivors route through
|
|
1538
|
+
the same anchor-resolution + insertion routine as the finalizer
|
|
1539
|
+
path, preserving consumption order (an anchor miss appends at the
|
|
1540
|
+
end, which is still before the checkpoint's terminal markers —
|
|
1541
|
+
callers run this in the pre-shaping window).
|
|
1542
|
+
"""
|
|
1543
|
+
if not injections:
|
|
1544
|
+
return
|
|
1545
|
+
state = self._state
|
|
1546
|
+
if state is None:
|
|
1547
|
+
return
|
|
1548
|
+
messages = state.get("messages")
|
|
1549
|
+
if not isinstance(messages, list):
|
|
1550
|
+
return
|
|
1551
|
+
start = current_turn_start(messages)
|
|
1552
|
+
region = messages[start:]
|
|
1553
|
+
region_injections_by_id = {
|
|
1554
|
+
injection_id: message
|
|
1555
|
+
for message in region
|
|
1556
|
+
if isinstance(
|
|
1557
|
+
injection_id := message.additional_properties.get(HistoryMarkerKind.INJECTION_ID_KEY),
|
|
1558
|
+
str,
|
|
1559
|
+
)
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
missing: list[ConsumedInjection] = []
|
|
1563
|
+
for injection in injections:
|
|
1564
|
+
if injection.consumption_id is not None:
|
|
1565
|
+
existing = region_injections_by_id.get(injection.consumption_id)
|
|
1566
|
+
else:
|
|
1567
|
+
existing = next(
|
|
1568
|
+
(message for message in region if user_text_matches(message, injection.text, kind="injected")),
|
|
1569
|
+
None,
|
|
1570
|
+
)
|
|
1571
|
+
if existing is None:
|
|
1572
|
+
missing.append(injection)
|
|
1573
|
+
else:
|
|
1574
|
+
_stamp_consumption(existing, injection)
|
|
1575
|
+
_insert_consumed_injection_messages(messages, missing)
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def _stamp_consumption(message: Message, injection: ConsumedInjection) -> None:
|
|
1579
|
+
"""Give a persisted copy of *injection* the identity, time and reminders its consumption had."""
|
|
1580
|
+
ensure_analytics_item_id(message.additional_properties, item_id=injection.analytics_item_id)
|
|
1581
|
+
if injection.created_at is not None:
|
|
1582
|
+
stamp_message_created_at(message, injection.created_at)
|
|
1583
|
+
copy_reminder_record(injection.wire_properties, message.additional_properties)
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def _insert_consumed_injection_messages(messages: list[Message], injections: list[ConsumedInjection]) -> None:
|
|
1587
|
+
"""Insert consumed-injection messages at their anchor positions.
|
|
1588
|
+
|
|
1589
|
+
Shared core of :meth:`SessionHistoryManager.persist_consumed_injections`
|
|
1590
|
+
(finalizer path) and :meth:`~SessionHistoryManager.replay_consumed_injections`
|
|
1591
|
+
(crash-recovery checkpoint path) — both must place injections identically.
|
|
1592
|
+
|
|
1593
|
+
Anchors resolve against the **pre-insertion** list (batch resolve), then
|
|
1594
|
+
insertions apply highest index first so earlier indices are not shifted.
|
|
1595
|
+
For equal indices (multiple injections sharing one anchor), LATER-queued
|
|
1596
|
+
inserts first — each insert pushes prior items down, so the last insert
|
|
1597
|
+
lands at the anchor position, which is the earliest queued. Final
|
|
1598
|
+
ascending-index order matches queue order. An anchor miss appends at
|
|
1599
|
+
the end, which also preserves queue order.
|
|
1600
|
+
"""
|
|
1601
|
+
insertions: list[tuple[int, int, Message]] = []
|
|
1602
|
+
for queue_order, injection in enumerate(injections):
|
|
1603
|
+
match = injection.anchor.find_in(messages)
|
|
1604
|
+
insert_idx = match + 1 if match is not None else len(messages)
|
|
1605
|
+
inj_msg = Message("user", [injection.text])
|
|
1606
|
+
inj_msg.additional_properties[HistoryMarkerKind.INJECTED_KEY] = True
|
|
1607
|
+
if injection.consumption_id:
|
|
1608
|
+
# Per-consumption identity: replay_consumed_injections dedups on
|
|
1609
|
+
# this stamp, never on text alone.
|
|
1610
|
+
inj_msg.additional_properties[HistoryMarkerKind.INJECTION_ID_KEY] = injection.consumption_id
|
|
1611
|
+
_stamp_consumption(inj_msg, injection)
|
|
1612
|
+
insertions.append((insert_idx, queue_order, inj_msg))
|
|
1613
|
+
|
|
1614
|
+
insertions.sort(key=lambda t: (t[0], t[1]), reverse=True)
|
|
1615
|
+
for idx, _order, msg in insertions:
|
|
1616
|
+
messages.insert(idx, msg)
|