octo-agent 0.11.2
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.
- checksums.yaml +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- metadata +614 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
# MessageHistory wraps the conversation message list and exposes
|
|
5
|
+
# business-meaningful operations instead of raw array manipulation.
|
|
6
|
+
#
|
|
7
|
+
# Internal fields (task_id, created_at, system_injected, etc.) are kept
|
|
8
|
+
# in the internal store but stripped when calling #to_api.
|
|
9
|
+
class MessageHistory
|
|
10
|
+
# Fields that are internal to the agent and must not be sent to the API.
|
|
11
|
+
INTERNAL_FIELDS = %i[
|
|
12
|
+
task_id created_at system_injected session_context memory_update
|
|
13
|
+
subagent_instructions subagent_result token_usage
|
|
14
|
+
compressed_summary chunk_path truncated transient
|
|
15
|
+
chunk_index chunk_count
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(messages = [])
|
|
19
|
+
@messages = messages.dup
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# ─────────────────────────────────────────────
|
|
23
|
+
# Write operations
|
|
24
|
+
# ─────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
# Append a single message hash to the history.
|
|
27
|
+
#
|
|
28
|
+
# When appending a user message, automatically drop any trailing assistant
|
|
29
|
+
# message that has unanswered tool_calls (no tool_result follows it).
|
|
30
|
+
# This prevents API error 2013 ("tool call result does not follow tool call")
|
|
31
|
+
# when a previous task ended before observe() could append tool results
|
|
32
|
+
# (e.g. subagent crash, interrupt, or error).
|
|
33
|
+
def append(message)
|
|
34
|
+
if message[:role] == "user"
|
|
35
|
+
drop_dangling_tool_calls!
|
|
36
|
+
end
|
|
37
|
+
@messages << deep_sanitize_utf8(message)
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Replace (or insert at head) the system prompt message.
|
|
42
|
+
# Used by session_serializer#refresh_system_prompt.
|
|
43
|
+
def replace_system_prompt(content, **extra)
|
|
44
|
+
msg = { role: "system", content: content }.merge(extra)
|
|
45
|
+
idx = @messages.index { |m| m[:role] == "system" }
|
|
46
|
+
if idx
|
|
47
|
+
@messages[idx] = msg
|
|
48
|
+
else
|
|
49
|
+
@messages.unshift(msg)
|
|
50
|
+
end
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Replace the entire message list (used by compression rebuild).
|
|
55
|
+
def replace_all(new_messages)
|
|
56
|
+
@messages = new_messages.map { |m| deep_sanitize_utf8(m) }
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Remove and return the last message.
|
|
61
|
+
def pop_last
|
|
62
|
+
@messages.pop
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Remove all messages matching the block in-place.
|
|
66
|
+
# Generic history pruning utility — used by callers that need to
|
|
67
|
+
# strip transient/system-injected messages out of the persisted
|
|
68
|
+
# history (e.g. compaction, rollback on 400 errors).
|
|
69
|
+
def delete_where(&block)
|
|
70
|
+
@messages.reject!(&block)
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Mutate the last message matching the predicate lambda in-place.
|
|
75
|
+
# Used by execute_skill_with_subagent to update instruction messages.
|
|
76
|
+
def mutate_last_matching(predicate, &block)
|
|
77
|
+
msg = @messages.reverse.find { |m| predicate.call(m) }
|
|
78
|
+
block.call(msg) if msg
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Remove all messages from index onward (used by restore_session on error).
|
|
83
|
+
def truncate_from(index)
|
|
84
|
+
@messages = @messages[0...index]
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Roll back the history to just before the given message object.
|
|
89
|
+
# Removes the message and anything appended after it.
|
|
90
|
+
# Used to undo a failed speculative append (e.g. compression message that errored).
|
|
91
|
+
def rollback_before(message)
|
|
92
|
+
idx = @messages.index { |m| m.equal?(message) }
|
|
93
|
+
return self unless idx
|
|
94
|
+
|
|
95
|
+
@messages = @messages[0...idx]
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ─────────────────────────────────────────────
|
|
100
|
+
# Business queries
|
|
101
|
+
# ─────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
# True when a system prompt message is present in the history.
|
|
104
|
+
# Used by inject_session_context to avoid injecting context messages
|
|
105
|
+
# before the system prompt has been built (which would cause the
|
|
106
|
+
# guard in run() to skip building it altogether).
|
|
107
|
+
def has_system_prompt?
|
|
108
|
+
@messages.any? { |m| m[:role] == "system" }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# True when the last assistant message has tool_calls but no
|
|
112
|
+
# tool_result has been appended yet (would cause a 400 from the API).
|
|
113
|
+
def pending_tool_calls?
|
|
114
|
+
return false if @messages.empty?
|
|
115
|
+
|
|
116
|
+
last = @messages.last
|
|
117
|
+
return false unless last[:role] == "assistant" && last[:tool_calls]&.any?
|
|
118
|
+
|
|
119
|
+
last_assistant_idx = @messages.rindex { |m| m == last }
|
|
120
|
+
@messages[(last_assistant_idx + 1)..].none? { |m| m[:role] == "tool" || m[:tool_results] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Return the session_date value from the most recent session_context message.
|
|
124
|
+
# Used by inject_session_context_if_needed to avoid re-injecting on the same date.
|
|
125
|
+
def last_session_context_date
|
|
126
|
+
msg = @messages.reverse.find { |m| m[:session_context] }
|
|
127
|
+
msg&.dig(:session_date)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Return the chunk_count from the most recently injected chunk index message.
|
|
131
|
+
# Used by inject_chunk_index_if_needed to avoid re-injecting when nothing changed.
|
|
132
|
+
def last_injected_chunk_count
|
|
133
|
+
msg = @messages.reverse.find { |m| m[:chunk_index] }
|
|
134
|
+
msg&.dig(:chunk_count) || 0
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Return only real (non-system-injected) user messages.
|
|
138
|
+
def real_user_messages
|
|
139
|
+
@messages.select { |m| m[:role] == "user" && !m[:system_injected] }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Return the index of the last real (non-system-injected) user message.
|
|
143
|
+
# Used by restore_session to trim back to a clean state on error.
|
|
144
|
+
def last_real_user_index
|
|
145
|
+
@messages.rindex { |m| m[:role] == "user" && !m[:system_injected] }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Return the message with :subagent_instructions set.
|
|
149
|
+
def subagent_instruction_message
|
|
150
|
+
@messages.find { |m| m[:subagent_instructions] }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Return all messages where task_id <= given id (Time Machine support).
|
|
154
|
+
def for_task(task_id)
|
|
155
|
+
@messages.select { |m| !m[:task_id] || m[:task_id] <= task_id }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ─────────────────────────────────────────────
|
|
159
|
+
# Size helpers
|
|
160
|
+
# ─────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def size
|
|
163
|
+
@messages.size
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def empty?
|
|
167
|
+
@messages.empty?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Estimate total token count for all messages.
|
|
171
|
+
# Uses the ~4 chars/token heuristic (works well for English/code).
|
|
172
|
+
# Handles string content, array content blocks, and tool_calls.
|
|
173
|
+
def estimate_tokens
|
|
174
|
+
@messages.sum { |m| estimate_message_tokens(m) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ─────────────────────────────────────────────
|
|
178
|
+
# Output
|
|
179
|
+
# ─────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
# Return a clean copy of messages suitable for sending to the LLM API:
|
|
182
|
+
# - strips internal-only fields
|
|
183
|
+
# - pads reasoning_content on synthetic assistant messages when the
|
|
184
|
+
# conversation is running against a thinking-mode provider
|
|
185
|
+
#
|
|
186
|
+
# @param force_reasoning_content_pad [Boolean]
|
|
187
|
+
# When true, unconditionally pad every assistant message that lacks a
|
|
188
|
+
# reasoning_content field with an empty string. This is set by the
|
|
189
|
+
# LLM caller AFTER a 400 "reasoning_content must be passed back" error
|
|
190
|
+
# as a one-shot retry signal — the history-evidence heuristic below
|
|
191
|
+
# can't fire when the previous turns came from a provider that keeps
|
|
192
|
+
# thinking inline (e.g. MiniMax: <think>...</think> in content), so
|
|
193
|
+
# this bypass lets us recover on the retry without a server restart.
|
|
194
|
+
def to_api(force_reasoning_content_pad: false)
|
|
195
|
+
msgs = @messages.map { |m| strip_for_api(m) }
|
|
196
|
+
ensure_reasoning_content_consistency(msgs, force: force_reasoning_content_pad)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Return a shallow copy of the message list, excluding transient messages.
|
|
200
|
+
# Transient messages are valid during the current session but must not be
|
|
201
|
+
# persisted to session.json.
|
|
202
|
+
# For serialization, compression, and cloning.
|
|
203
|
+
def to_a
|
|
204
|
+
@messages.reject { |m| m[:transient] }.dup
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Estimate token count for a single message (role overhead + content).
|
|
208
|
+
private def estimate_message_tokens(message)
|
|
209
|
+
# ~4 tokens of overhead per message (role, formatting)
|
|
210
|
+
tokens = 4
|
|
211
|
+
tokens += estimate_content_tokens(message[:content])
|
|
212
|
+
|
|
213
|
+
# tool_calls: each call adds name + arguments chars
|
|
214
|
+
if message[:tool_calls].is_a?(Array)
|
|
215
|
+
message[:tool_calls].each do |tc|
|
|
216
|
+
tokens += estimate_content_tokens(tc.dig(:function, :name))
|
|
217
|
+
tokens += estimate_content_tokens(tc.dig(:function, :arguments))
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
tokens
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Estimate tokens from a content value (string, array of blocks, or nil).
|
|
225
|
+
# Heuristic: ASCII/code ~4 chars/token; CJK/multibyte ~1.5 chars/token.
|
|
226
|
+
private def estimate_content_tokens(content)
|
|
227
|
+
case content
|
|
228
|
+
when String
|
|
229
|
+
ascii_chars = content.scan(/[ -~]/).length
|
|
230
|
+
multibyte_chars = content.length - ascii_chars
|
|
231
|
+
((ascii_chars / 4.0) + (multibyte_chars / 1.5)).ceil
|
|
232
|
+
when Array
|
|
233
|
+
content.sum do |block|
|
|
234
|
+
block.is_a?(Hash) ? estimate_content_tokens(block[:text] || block["text"]) : 0
|
|
235
|
+
end
|
|
236
|
+
else
|
|
237
|
+
0
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Drop the trailing assistant message if it has tool_calls with no subsequent
|
|
242
|
+
# tool_result — i.e. the tool call was never answered (dangling).
|
|
243
|
+
# Called automatically before appending any user message.
|
|
244
|
+
private def drop_dangling_tool_calls!
|
|
245
|
+
return unless pending_tool_calls?
|
|
246
|
+
|
|
247
|
+
@messages.pop
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private def strip_for_api(message)
|
|
251
|
+
msg = strip_internal_fields(message)
|
|
252
|
+
content = msg[:content]
|
|
253
|
+
return msg unless content.is_a?(Array)
|
|
254
|
+
|
|
255
|
+
cleaned = content.filter_map do |block|
|
|
256
|
+
next block unless block.is_a?(Hash)
|
|
257
|
+
|
|
258
|
+
if block[:type] == "image_url" &&
|
|
259
|
+
block.dig(:image_url, :url) == "[image stripped]"
|
|
260
|
+
next nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
block.key?(:image_path) ? block.reject { |k, _| k == :image_path } : block
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
return msg if cleaned == content
|
|
267
|
+
|
|
268
|
+
if cleaned.empty?
|
|
269
|
+
msg.merge(content: "[images were shown to you in a previous turn]")
|
|
270
|
+
else
|
|
271
|
+
msg.merge(content: cleaned)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
private def strip_internal_fields(message)
|
|
276
|
+
message.reject { |k, _| INTERNAL_FIELDS.include?(k) }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Detect thinking-mode providers purely from history content and pad
|
|
280
|
+
# synthetic assistant messages with an empty reasoning_content when needed.
|
|
281
|
+
#
|
|
282
|
+
# WHY: Providers like DeepSeek V4 and Kimi K2 in thinking mode return a
|
|
283
|
+
# `reasoning_content` field on every assistant turn and REQUIRE the caller
|
|
284
|
+
# to echo a `reasoning_content` field back on every subsequent assistant
|
|
285
|
+
# message in the payload — omitting it triggers:
|
|
286
|
+
# HTTP 400: "The reasoning_content in the thinking mode must be passed
|
|
287
|
+
# back to the API"
|
|
288
|
+
#
|
|
289
|
+
# The canonical history contains assistant messages from two sources:
|
|
290
|
+
# 1. Real LLM responses — carry reasoning_content when returned by the
|
|
291
|
+
# provider (preserved in agent.rb via parse_response).
|
|
292
|
+
# 2. Synthetic / locally-injected messages — skill injection, subagent
|
|
293
|
+
# acks, slash-command notices, truncation fallbacks. These are never
|
|
294
|
+
# produced by the LLM so they naturally lack reasoning_content.
|
|
295
|
+
#
|
|
296
|
+
# RULE: If ANY assistant message in the history carries reasoning_content,
|
|
297
|
+
# the conversation is provably running against a thinking-mode provider
|
|
298
|
+
# (the provider itself produced it). In that case, every other assistant
|
|
299
|
+
# message must echo the field, so we pad with an empty string.
|
|
300
|
+
#
|
|
301
|
+
# This is a purely structural inference with no model-name coupling —
|
|
302
|
+
# it self-adapts to new thinking-mode providers and new synthetic-message
|
|
303
|
+
# injection sites without any code changes elsewhere.
|
|
304
|
+
#
|
|
305
|
+
# For non-thinking providers (Claude / OpenAI / Gemini / Bedrock) no
|
|
306
|
+
# assistant message ever has reasoning_content, so this is a no-op.
|
|
307
|
+
# The Anthropic adapter also filters unknown fields via a whitelist, so
|
|
308
|
+
# even mid-session fallback between providers remains safe.
|
|
309
|
+
private def ensure_reasoning_content_consistency(msgs, force: false)
|
|
310
|
+
self.class.pad_reasoning_content_if_needed(msgs, force: force)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Public helper: pad assistant messages that lack a reasoning_content
|
|
314
|
+
# field with an empty string, either when forced or when the payload
|
|
315
|
+
# already shows evidence of thinking-mode (at least one assistant
|
|
316
|
+
# message with reasoning_content).
|
|
317
|
+
#
|
|
318
|
+
# Exposed as a class method so Time Machine's active_messages path can
|
|
319
|
+
# reuse the exact same logic without routing through #to_api.
|
|
320
|
+
def self.pad_reasoning_content_if_needed(msgs, force: false)
|
|
321
|
+
should_pad = force || msgs.any? { |m| m[:role] == "assistant" && m[:reasoning_content] }
|
|
322
|
+
return msgs unless should_pad
|
|
323
|
+
|
|
324
|
+
msgs.map do |m|
|
|
325
|
+
next m unless m[:role] == "assistant"
|
|
326
|
+
next m if m.key?(:reasoning_content)
|
|
327
|
+
|
|
328
|
+
m.merge(reasoning_content: "")
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Defense-in-depth: recursively scrub invalid UTF-8 bytes from every String
|
|
333
|
+
# stored in the message tree. Even if a tool forgets to scrub its output,
|
|
334
|
+
# nothing poisoned will ever reach session persistence or JSON.generate.
|
|
335
|
+
#
|
|
336
|
+
# Fast path: if the tree contains only valid UTF-8 strings, the original
|
|
337
|
+
# object is returned unchanged — preserving object identity for callers
|
|
338
|
+
# that rely on `equal?` (e.g. rollback_before).
|
|
339
|
+
# Slow path: any invalid byte triggers a rebuild with scrubbed strings
|
|
340
|
+
# (invalid bytes → U+FFFD).
|
|
341
|
+
private def deep_sanitize_utf8(obj)
|
|
342
|
+
case obj
|
|
343
|
+
when String
|
|
344
|
+
return obj if obj.encoding == Encoding::UTF_8 && obj.valid_encoding?
|
|
345
|
+
obj.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
|
|
346
|
+
when Hash
|
|
347
|
+
return obj unless contains_dirty_utf8?(obj)
|
|
348
|
+
obj.transform_values { |v| deep_sanitize_utf8(v) }
|
|
349
|
+
when Array
|
|
350
|
+
return obj unless contains_dirty_utf8?(obj)
|
|
351
|
+
obj.map { |v| deep_sanitize_utf8(v) }
|
|
352
|
+
else
|
|
353
|
+
obj
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Cheap recursive check: does this subtree contain any invalid-UTF-8 string?
|
|
358
|
+
# Short-circuits on first offender. Keeps the common case (all valid UTF-8)
|
|
359
|
+
# allocation-free.
|
|
360
|
+
private def contains_dirty_utf8?(obj)
|
|
361
|
+
case obj
|
|
362
|
+
when String
|
|
363
|
+
!(obj.encoding == Encoding::UTF_8 && obj.valid_encoding?)
|
|
364
|
+
when Hash
|
|
365
|
+
obj.any? { |_, v| contains_dirty_utf8?(v) }
|
|
366
|
+
when Array
|
|
367
|
+
obj.any? { |v| contains_dirty_utf8?(v) }
|
|
368
|
+
else
|
|
369
|
+
false
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
# Reassembles an OpenAI-compatible chat-completion event stream into the
|
|
7
|
+
# non-streaming response shape that MessageFormat::OpenAI.parse_response
|
|
8
|
+
# consumes, while invoking on_chunk(input_tokens:, output_tokens:) every
|
|
9
|
+
# time the upstream emits a new usage frame.
|
|
10
|
+
#
|
|
11
|
+
# Streaming frames look like:
|
|
12
|
+
#
|
|
13
|
+
# {"id":"...","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
|
|
14
|
+
# {"id":"...","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}
|
|
15
|
+
# {"id":"...","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_x","function":{"name":"shell","arguments":"{\"cmd"}}]}}]}
|
|
16
|
+
# {"id":"...","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\"ls\"}"}}]}}]}
|
|
17
|
+
# {"id":"...","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
|
|
18
|
+
# {"id":"...","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":2}}}
|
|
19
|
+
# data: [DONE]
|
|
20
|
+
class OpenAIStreamAggregator
|
|
21
|
+
def initialize(on_chunk: nil)
|
|
22
|
+
@on_chunk = on_chunk
|
|
23
|
+
@content = +""
|
|
24
|
+
@reasoning_content = +""
|
|
25
|
+
@role = "assistant"
|
|
26
|
+
@finish_reason = nil
|
|
27
|
+
@tool_calls = {}
|
|
28
|
+
@usage = nil
|
|
29
|
+
@last_input_tokens = 0
|
|
30
|
+
@last_output_tokens = 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def handle(data_str)
|
|
34
|
+
return if data_str == "[DONE]"
|
|
35
|
+
data = parse_or_nil(data_str)
|
|
36
|
+
return unless data
|
|
37
|
+
|
|
38
|
+
if (choice = (data["choices"] || []).first)
|
|
39
|
+
delta = choice["delta"] || {}
|
|
40
|
+
@role = delta["role"] if delta["role"]
|
|
41
|
+
@content << delta["content"] if delta["content"].is_a?(String)
|
|
42
|
+
@reasoning_content << delta["reasoning_content"] if delta["reasoning_content"].is_a?(String)
|
|
43
|
+
if (tcs = delta["tool_calls"])
|
|
44
|
+
tcs.each { |tc| merge_tool_call(tc) }
|
|
45
|
+
end
|
|
46
|
+
@finish_reason = choice["finish_reason"] if choice["finish_reason"]
|
|
47
|
+
emit_estimate_progress
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if (u = data["usage"])
|
|
51
|
+
@usage = u
|
|
52
|
+
emit_usage_progress(u)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Render the canonical non-streaming response shape.
|
|
57
|
+
def to_h
|
|
58
|
+
tool_calls = @tool_calls.keys.sort.map do |idx|
|
|
59
|
+
tc = @tool_calls[idx]
|
|
60
|
+
{
|
|
61
|
+
"id" => tc[:id],
|
|
62
|
+
"type" => tc[:type] || "function",
|
|
63
|
+
"function" => {
|
|
64
|
+
"name" => tc[:name],
|
|
65
|
+
"arguments" => tc[:arguments].to_s
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
message = {
|
|
71
|
+
"role" => @role,
|
|
72
|
+
"content" => @content.empty? ? nil : @content
|
|
73
|
+
}
|
|
74
|
+
message["tool_calls"] = tool_calls unless tool_calls.empty?
|
|
75
|
+
message["reasoning_content"] = @reasoning_content unless @reasoning_content.empty?
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
"choices" => [{ "index" => 0, "message" => message, "finish_reason" => @finish_reason }],
|
|
79
|
+
"usage" => @usage || {}
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private def merge_tool_call(tc)
|
|
84
|
+
idx = tc["index"] || @tool_calls.size
|
|
85
|
+
slot = (@tool_calls[idx] ||= { id: nil, type: nil, name: nil, arguments: +"" })
|
|
86
|
+
slot[:id] ||= tc["id"] if tc["id"]
|
|
87
|
+
slot[:type] ||= tc["type"] if tc["type"]
|
|
88
|
+
if (fn = tc["function"])
|
|
89
|
+
slot[:name] ||= fn["name"] if fn["name"]
|
|
90
|
+
slot[:arguments] << fn["arguments"].to_s if fn["arguments"]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private def parse_or_nil(s)
|
|
95
|
+
JSON.parse(s)
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private def emit_estimate_progress
|
|
101
|
+
return unless @on_chunk
|
|
102
|
+
output = approximate_output_tokens
|
|
103
|
+
return if output == @last_output_tokens
|
|
104
|
+
@last_output_tokens = output
|
|
105
|
+
@on_chunk.call(input_tokens: @last_input_tokens, output_tokens: output)
|
|
106
|
+
rescue => e
|
|
107
|
+
Octo::Logger.warn("[OpenAIStreamAggregator] on_chunk: #{e.class}: #{e.message}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Rough char/4 estimate; replaced by the real count when the upstream
|
|
111
|
+
# finally emits a usage frame (with stream_options.include_usage=true).
|
|
112
|
+
private def approximate_output_tokens
|
|
113
|
+
total_chars = @content.bytesize + @reasoning_content.bytesize +
|
|
114
|
+
@tool_calls.values.sum { |tc| tc[:arguments].to_s.bytesize }
|
|
115
|
+
(total_chars / 4.0).ceil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private def emit_usage_progress(u)
|
|
119
|
+
return unless @on_chunk
|
|
120
|
+
total_prompt = u["prompt_tokens"].to_i
|
|
121
|
+
output = u["completion_tokens"].to_i
|
|
122
|
+
return if total_prompt == @last_input_tokens && output == @last_output_tokens
|
|
123
|
+
@last_input_tokens = total_prompt
|
|
124
|
+
@last_output_tokens = output
|
|
125
|
+
@on_chunk.call(input_tokens: total_prompt, output_tokens: output)
|
|
126
|
+
rescue => e
|
|
127
|
+
Octo::Logger.warn("[OpenAIStreamAggregator] on_chunk: #{e.class}: #{e.message}")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ui_interface"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
# PlainUIController implements UIInterface for non-interactive (--message) mode.
|
|
7
|
+
# Writes human-readable plain text directly to stdout so the caller can capture
|
|
8
|
+
# or pipe the output. No spinners, no TUI — just clean lines.
|
|
9
|
+
class PlainUIController
|
|
10
|
+
include Octo::UIInterface
|
|
11
|
+
|
|
12
|
+
def initialize(output: $stdout)
|
|
13
|
+
@output = output
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# === Output display ===
|
|
18
|
+
|
|
19
|
+
def show_assistant_message(content, files:)
|
|
20
|
+
puts_line(content) unless content.nil? || content.strip.empty?
|
|
21
|
+
files.each { |f| puts_line("📄 File: #{f[:path]}") }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show_tool_call(name, args)
|
|
25
|
+
args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
|
|
26
|
+
|
|
27
|
+
# Special handling for request_user_feedback — display as a readable prompt
|
|
28
|
+
if name.to_s == "request_user_feedback"
|
|
29
|
+
question = args_data.is_a?(Hash) ? (args_data[:question] || args_data["question"]).to_s : ""
|
|
30
|
+
context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
|
|
31
|
+
options = args_data.is_a?(Hash) ? (args_data[:options] || args_data["options"]) : nil
|
|
32
|
+
options = Array(options) if options && !options.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
parts = []
|
|
35
|
+
parts << "**Context:** #{context.strip}" if context && !context.strip.empty?
|
|
36
|
+
parts << "**Question:** #{question.strip}"
|
|
37
|
+
if options && !options.empty?
|
|
38
|
+
parts << "**Options:**"
|
|
39
|
+
options.each_with_index { |opt, i| parts << " #{i + 1}. #{opt}" }
|
|
40
|
+
end
|
|
41
|
+
puts_line(parts.join("\n"))
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
display = case name
|
|
46
|
+
when "terminal"
|
|
47
|
+
cmd = args_data.is_a?(Hash) ? (args_data[:command] || args_data["command"]) : args_data
|
|
48
|
+
sid = args_data.is_a?(Hash) ? (args_data[:session_id] || args_data["session_id"]) : nil
|
|
49
|
+
if cmd
|
|
50
|
+
"$ #{cmd}"
|
|
51
|
+
elsif sid
|
|
52
|
+
"$ (session ##{sid})"
|
|
53
|
+
else
|
|
54
|
+
"$ terminal"
|
|
55
|
+
end
|
|
56
|
+
when "write"
|
|
57
|
+
path = args_data.is_a?(Hash) ? (args_data[:path] || args_data["path"]) : args_data
|
|
58
|
+
"Write → #{path}"
|
|
59
|
+
when "edit"
|
|
60
|
+
path = args_data.is_a?(Hash) ? (args_data[:path] || args_data["path"]) : args_data
|
|
61
|
+
"Edit → #{path}"
|
|
62
|
+
else
|
|
63
|
+
label = args_data.is_a?(Hash) ? args_data.map { |k, v| "#{k}=#{v.to_s[0..40]}" }.join(", ") : args_data.to_s[0..80]
|
|
64
|
+
"#{name}(#{label})"
|
|
65
|
+
end
|
|
66
|
+
puts_line("[tool] #{display}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def show_tool_result(result)
|
|
70
|
+
text = result.to_s.strip
|
|
71
|
+
return if text.empty?
|
|
72
|
+
|
|
73
|
+
# Indent multi-line results for readability
|
|
74
|
+
indented = text.lines.map { |l| " #{l}" }.join
|
|
75
|
+
puts_line(indented)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def show_tool_error(error)
|
|
79
|
+
msg = error.is_a?(Exception) ? error.message : error.to_s
|
|
80
|
+
puts_line("[error] #{msg}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def show_file_write_preview(path, is_new_file:)
|
|
84
|
+
action = is_new_file ? "create" : "overwrite"
|
|
85
|
+
puts_line("[file] #{action}: #{path}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def show_file_edit_preview(path)
|
|
89
|
+
puts_line("[file] edit: #{path}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def show_file_error(error_message)
|
|
93
|
+
puts_line("[file error] #{error_message}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def show_shell_preview(command)
|
|
97
|
+
puts_line("[shell] #{command}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def show_complete(iterations:, duration: nil, cache_stats: nil, awaiting_user_feedback: false)
|
|
101
|
+
parts = ["[done] iterations=#{iterations}"]
|
|
102
|
+
parts << "duration=#{duration.round(1)}s" if duration
|
|
103
|
+
puts_line(parts.join(" "))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def append_output(content)
|
|
107
|
+
puts_line(content)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# === Status messages ===
|
|
111
|
+
|
|
112
|
+
def show_info(message, prefix_newline: true)
|
|
113
|
+
puts_line("[info] #{message}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def show_warning(message)
|
|
117
|
+
puts_line("[warn] #{message}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def show_error(message)
|
|
121
|
+
puts_line("[error] #{message}")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def show_success(message)
|
|
125
|
+
puts_line("[ok] #{message}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def log(message, level: :info)
|
|
129
|
+
# Only surface errors/warnings; suppress debug noise in plain mode
|
|
130
|
+
puts_line("[#{level}] #{message}") if %i[error warn].include?(level.to_sym)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# === Progress (no-ops — no spinner in plain mode) ===
|
|
134
|
+
|
|
135
|
+
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
|
|
136
|
+
|
|
137
|
+
# === State updates (no-ops) ===
|
|
138
|
+
|
|
139
|
+
def update_sessionbar(tasks: nil, status: nil, latency: nil); end
|
|
140
|
+
def update_todos(todos); end
|
|
141
|
+
def set_working_status; end
|
|
142
|
+
def set_idle_status; end
|
|
143
|
+
|
|
144
|
+
# === Blocking interaction (auto-approve in non-interactive mode) ===
|
|
145
|
+
|
|
146
|
+
def request_confirmation(message, default: true)
|
|
147
|
+
# Should not be reached because permission_mode is forced to auto_approve,
|
|
148
|
+
# but return true as a safety net.
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# === Input control / Lifecycle (no-ops) ===
|
|
153
|
+
|
|
154
|
+
def clear_input; end
|
|
155
|
+
def set_input_tips(message, type: :info); end
|
|
156
|
+
def stop; end
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def puts_line(text)
|
|
160
|
+
@mutex.synchronize do
|
|
161
|
+
@output.puts(text)
|
|
162
|
+
@output.flush
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|