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,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require_relative "ui_interface"
|
|
6
|
+
|
|
7
|
+
module Octo
|
|
8
|
+
# JsonUIController implements UIInterface for JSON (NDJSON) output mode.
|
|
9
|
+
# All output is written as one-JSON-per-line to stdout.
|
|
10
|
+
# Confirmation requests read responses from stdin.
|
|
11
|
+
class JsonUIController
|
|
12
|
+
include Octo::UIInterface
|
|
13
|
+
|
|
14
|
+
def initialize(output: $stdout, input: $stdin)
|
|
15
|
+
@output = output
|
|
16
|
+
@input = input
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Emit a raw NDJSON event
|
|
21
|
+
def emit(type, **data)
|
|
22
|
+
event = { type: type }.merge(data)
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
@output.puts(JSON.generate(event))
|
|
25
|
+
@output.flush
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# === Output display ===
|
|
30
|
+
|
|
31
|
+
def show_assistant_message(content, files:)
|
|
32
|
+
return if (content.nil? || content.strip.empty?) && files.empty?
|
|
33
|
+
|
|
34
|
+
data = { content: content.to_s }
|
|
35
|
+
data[:files] = files if files.any?
|
|
36
|
+
emit("assistant_message", **data)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def show_tool_call(name, args)
|
|
40
|
+
args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
|
|
41
|
+
emit("tool_call", name: name, args: args_data)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def show_tool_result(result)
|
|
45
|
+
emit("tool_result", result: result)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def show_tool_error(error)
|
|
49
|
+
error_msg = error.is_a?(Exception) ? error.message : error.to_s
|
|
50
|
+
emit("tool_error", error: error_msg)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def show_tool_args(formatted_args)
|
|
54
|
+
emit("tool_args", args: formatted_args)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def show_file_write_preview(path, is_new_file:)
|
|
58
|
+
emit("file_preview", path: path, operation: "write", is_new_file: is_new_file)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def show_file_edit_preview(path)
|
|
62
|
+
emit("file_preview", path: path, operation: "edit")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def show_file_error(error_message)
|
|
66
|
+
emit("file_error", error: error_message)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def show_shell_preview(command)
|
|
70
|
+
emit("shell_preview", command: command)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def show_diff(old_content, new_content, max_lines: 50)
|
|
74
|
+
emit("diff", old_size: old_content.bytesize, new_size: new_content.bytesize)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def show_token_usage(token_data)
|
|
78
|
+
emit("token_usage", **token_data)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def show_complete(iterations:, duration: nil, cache_stats: nil, awaiting_user_feedback: false)
|
|
82
|
+
data = { iterations: iterations }
|
|
83
|
+
data[:duration] = duration if duration
|
|
84
|
+
data[:cache_stats] = cache_stats if cache_stats
|
|
85
|
+
data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
|
|
86
|
+
emit("complete", **data)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# append_output is a no-op in JSON mode (content is already emitted via semantic methods)
|
|
90
|
+
def append_output(content)
|
|
91
|
+
# no-op
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# === Status messages ===
|
|
95
|
+
|
|
96
|
+
def show_info(message, prefix_newline: true)
|
|
97
|
+
emit("info", message: message)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def show_warning(message)
|
|
101
|
+
emit("warning", message: message)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def show_error(message)
|
|
105
|
+
emit("error", message: message)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def show_success(message)
|
|
109
|
+
emit("success", message: message)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def log(message, level: :info)
|
|
113
|
+
emit("log", level: level.to_s, message: message)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# === Progress ===
|
|
117
|
+
|
|
118
|
+
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
|
|
119
|
+
@progress_start_time = Time.now if phase == "active"
|
|
120
|
+
|
|
121
|
+
data = {
|
|
122
|
+
message: message,
|
|
123
|
+
progress_type: progress_type,
|
|
124
|
+
phase: phase,
|
|
125
|
+
status: phase == "active" ? "start" : "stop" # backward compat
|
|
126
|
+
}
|
|
127
|
+
data[:metadata] = metadata unless metadata.empty?
|
|
128
|
+
data[:elapsed] = (Time.now - @progress_start_time).round(1) if phase == "done" && @progress_start_time
|
|
129
|
+
|
|
130
|
+
emit("progress", **data)
|
|
131
|
+
|
|
132
|
+
@progress_start_time = nil if phase == "done"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# === State updates ===
|
|
136
|
+
|
|
137
|
+
def update_sessionbar(tasks: nil, status: nil, latency: nil)
|
|
138
|
+
data = {}
|
|
139
|
+
data[:tasks] = tasks if tasks
|
|
140
|
+
data[:status] = status if status
|
|
141
|
+
data[:latency] = latency if latency
|
|
142
|
+
emit("session_update", **data) unless data.empty?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def update_todos(todos)
|
|
146
|
+
emit("todo_update", todos: todos)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def set_working_status
|
|
150
|
+
emit("session_update", status: "working")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def set_idle_status
|
|
154
|
+
emit("session_update", status: "idle")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# === Blocking interaction ===
|
|
158
|
+
|
|
159
|
+
def request_confirmation(message, default: true)
|
|
160
|
+
conf_id = "conf_#{SecureRandom.hex(4)}"
|
|
161
|
+
emit("request_confirmation", id: conf_id, message: message, default: default)
|
|
162
|
+
|
|
163
|
+
# Read response from stdin (blocking)
|
|
164
|
+
line = @input.gets
|
|
165
|
+
return default if line.nil?
|
|
166
|
+
|
|
167
|
+
begin
|
|
168
|
+
response = JSON.parse(line.strip)
|
|
169
|
+
result = response["result"] || response[:result]
|
|
170
|
+
|
|
171
|
+
case result.to_s.downcase
|
|
172
|
+
when "yes", "y" then true
|
|
173
|
+
when "no", "n" then false
|
|
174
|
+
else
|
|
175
|
+
# Return as feedback text
|
|
176
|
+
result.to_s
|
|
177
|
+
end
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
default
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# === Input control (no-ops in JSON mode) ===
|
|
184
|
+
|
|
185
|
+
def clear_input
|
|
186
|
+
# no-op
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def set_input_tips(message, type: :info)
|
|
190
|
+
# no-op
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def show_next_message_suggestion(text)
|
|
194
|
+
emit("next_message_suggestion", text: text.to_s)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# === Lifecycle ===
|
|
198
|
+
|
|
199
|
+
def stop
|
|
200
|
+
# no-op
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
module MessageFormat
|
|
5
|
+
# Static helpers for Anthropic API message format.
|
|
6
|
+
#
|
|
7
|
+
# Responsibilities:
|
|
8
|
+
# - Identify Anthropic-style messages stored in @messages
|
|
9
|
+
# - Convert internal @messages → Anthropic API request body
|
|
10
|
+
# - Parse Anthropic API response → internal format
|
|
11
|
+
# - Format tool results for the next turn
|
|
12
|
+
#
|
|
13
|
+
# Internal @messages always use OpenAI-style canonical format:
|
|
14
|
+
# assistant tool_calls: { role: "assistant", tool_calls: [{id:, function:{name:,arguments:}}] }
|
|
15
|
+
# tool result: { role: "tool", tool_call_id:, content: }
|
|
16
|
+
#
|
|
17
|
+
# This module converts that canonical format to Anthropic native on the way OUT,
|
|
18
|
+
# and converts Anthropic native back to canonical on the way IN.
|
|
19
|
+
module Anthropic
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# ── Message type identification ───────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
# Returns true if the message is an Anthropic-native tool result stored in
|
|
25
|
+
# @messages (role: "user" with content array containing tool_result blocks).
|
|
26
|
+
# NOTE: After the refactor, new tool results are stored in canonical format
|
|
27
|
+
# (role: "tool"). This helper handles legacy messages that might exist in
|
|
28
|
+
# older sessions.
|
|
29
|
+
def tool_result_message?(msg)
|
|
30
|
+
msg[:role] == "user" &&
|
|
31
|
+
msg[:content].is_a?(Array) &&
|
|
32
|
+
msg[:content].any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the tool_use_ids referenced in an Anthropic-native tool result message.
|
|
36
|
+
def tool_use_ids(msg)
|
|
37
|
+
return [] unless tool_result_message?(msg)
|
|
38
|
+
|
|
39
|
+
msg[:content].select { |b| b[:type] == "tool_result" }.map { |b| b[:tool_use_id] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# ── Request building ──────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
# Convert canonical @messages + tools into an Anthropic API request body.
|
|
45
|
+
# @param messages [Array<Hash>] canonical messages (may include system)
|
|
46
|
+
# @param model [String]
|
|
47
|
+
# @param tools [Array<Hash>] OpenAI-style tool definitions
|
|
48
|
+
# @param max_tokens [Integer]
|
|
49
|
+
# @param caching_enabled [Boolean]
|
|
50
|
+
# @param reasoning_effort [String, nil]
|
|
51
|
+
# @param base_url [String, nil] used to detect third-party Anthropic-compatible
|
|
52
|
+
# endpoints (e.g. Kimi /coding, DeepSeek /anthropic) so we can strip
|
|
53
|
+
# thinking-block signatures they cannot re-validate.
|
|
54
|
+
# @return [Hash] ready to serialize as JSON body
|
|
55
|
+
def build_request_body(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, base_url: nil)
|
|
56
|
+
system_messages = messages.select { |m| m[:role] == "system" }
|
|
57
|
+
regular_messages = messages.reject { |m| m[:role] == "system" }
|
|
58
|
+
|
|
59
|
+
system_text = system_messages.map { |m| extract_text(m[:content]) }.join("\n\n")
|
|
60
|
+
|
|
61
|
+
# Detect non-native Anthropic endpoints that speak the Anthropic protocol
|
|
62
|
+
# but cannot validate Anthropic-proprietary thinking signatures.
|
|
63
|
+
strip_thinking_signatures = base_url && !native_anthropic_endpoint?(base_url)
|
|
64
|
+
|
|
65
|
+
api_messages = regular_messages.map { |msg| to_api_message(msg, caching_enabled, strip_thinking_signatures) }
|
|
66
|
+
api_tools = tools&.map { |t| to_api_tool(t) }
|
|
67
|
+
|
|
68
|
+
if caching_enabled && api_tools&.any?
|
|
69
|
+
api_tools.last[:cache_control] = { type: "ephemeral" }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
body = { model: model, max_tokens: max_tokens, messages: api_messages }
|
|
73
|
+
body[:system] = system_text unless system_text.empty?
|
|
74
|
+
body[:tools] = api_tools if api_tools&.any?
|
|
75
|
+
|
|
76
|
+
if (effort = normalized_effort(reasoning_effort))
|
|
77
|
+
body[:thinking] = { type: "adaptive" }
|
|
78
|
+
body[:output_config] = { effort: effort }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
body
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns true for the official Anthropic API endpoint.
|
|
85
|
+
# Anything else (Kimi /coding, DeepSeek /anthropic, self-hosted proxies,
|
|
86
|
+
# etc.) is treated as third-party and gets thinking signatures stripped.
|
|
87
|
+
private_class_method def self.native_anthropic_endpoint?(base_url)
|
|
88
|
+
base_url.to_s.start_with?("https://api.anthropic.com")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private_class_method def self.normalized_effort(effort)
|
|
92
|
+
return nil if effort.nil? || effort.to_s.empty?
|
|
93
|
+
s = effort.to_s
|
|
94
|
+
%w[low medium high].include?(s) ? s : nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# ── Response parsing ──────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
# Parse Anthropic API response into canonical internal format.
|
|
100
|
+
# @param data [Hash] parsed JSON response body
|
|
101
|
+
# @return [Hash] canonical response: { content:, tool_calls:, finish_reason:, usage: }
|
|
102
|
+
def parse_response(data)
|
|
103
|
+
blocks = data["content"] || []
|
|
104
|
+
usage = data["usage"] || {}
|
|
105
|
+
|
|
106
|
+
content = blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join("")
|
|
107
|
+
|
|
108
|
+
reasoning_content = blocks.select { |b| b["type"] == "thinking" }.map { |b| b["thinking"] }.join("")
|
|
109
|
+
|
|
110
|
+
# tool_calls use canonical format (id, function: {name, arguments})
|
|
111
|
+
tool_calls = blocks.select { |b| b["type"] == "tool_use" }.map do |tc|
|
|
112
|
+
args = tc["input"].is_a?(String) ? tc["input"] : tc["input"].to_json
|
|
113
|
+
{ id: tc["id"], type: "function", name: tc["name"], arguments: args }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
finish_reason = case data["stop_reason"]
|
|
117
|
+
when "end_turn" then "stop"
|
|
118
|
+
when "tool_use" then "tool_calls"
|
|
119
|
+
when "max_tokens" then "length"
|
|
120
|
+
else data["stop_reason"]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Anthropic native `input_tokens` counts ONLY the non-cached, freshly-billed
|
|
124
|
+
# input — cache_read_input_tokens and cache_creation_input_tokens are
|
|
125
|
+
# reported separately and are disjoint from input_tokens.
|
|
126
|
+
#
|
|
127
|
+
# Normalise to the codebase's canonical shape (OpenAI-style) so downstream
|
|
128
|
+
# (ModelPricing.calculate_cost, CostTracker, show_token_usage) stays
|
|
129
|
+
# provider-agnostic:
|
|
130
|
+
#
|
|
131
|
+
# prompt_tokens = non_cached + cache_read (OpenAI convention:
|
|
132
|
+
# includes cache_read
|
|
133
|
+
# but NOT cache_write;
|
|
134
|
+
# ModelPricing does
|
|
135
|
+
# `regular_input = prompt_tokens - cache_read`.)
|
|
136
|
+
# completion_tokens = output
|
|
137
|
+
# total_tokens = THIS TURN'S new compute volume
|
|
138
|
+
# = raw_input + cache_creation + output
|
|
139
|
+
# (cache_read is excluded because hits are ~free /
|
|
140
|
+
# already-paid-for; cache_creation IS new work this
|
|
141
|
+
# turn even though it's billed at write_rate.)
|
|
142
|
+
# cache_read_input_tokens / cache_creation_input_tokens → independent fields
|
|
143
|
+
#
|
|
144
|
+
# total_tokens is purely presentational. CostTracker treats it as the
|
|
145
|
+
# per-iteration delta directly (no subtraction of previous_total), which
|
|
146
|
+
# is the correct reading when total_tokens already means "new work this
|
|
147
|
+
# turn" rather than "cumulative".
|
|
148
|
+
raw_input_tokens = usage["input_tokens"].to_i
|
|
149
|
+
cache_read = usage["cache_read_input_tokens"].to_i
|
|
150
|
+
cache_creation = usage["cache_creation_input_tokens"].to_i
|
|
151
|
+
output_tokens = usage["output_tokens"].to_i
|
|
152
|
+
|
|
153
|
+
prompt_tokens = raw_input_tokens + cache_read
|
|
154
|
+
|
|
155
|
+
usage_data = {
|
|
156
|
+
prompt_tokens: prompt_tokens,
|
|
157
|
+
completion_tokens: output_tokens,
|
|
158
|
+
# Per-turn new compute: what the server freshly processed this request.
|
|
159
|
+
# Excludes cache_read (nearly free, already-paid-for).
|
|
160
|
+
total_tokens: raw_input_tokens + cache_creation + output_tokens,
|
|
161
|
+
# Signal to CostTracker: total_tokens above is already the per-turn
|
|
162
|
+
# delta (not a running cumulative like OpenAI's). CostTracker should
|
|
163
|
+
# NOT subtract previous_total when this flag is truthy.
|
|
164
|
+
# OpenAI parse leaves this field unset; Bedrock may adopt the same
|
|
165
|
+
# convention in future if we normalise it there too.
|
|
166
|
+
total_is_per_turn: true
|
|
167
|
+
}
|
|
168
|
+
usage_data[:cache_read_input_tokens] = cache_read if cache_read > 0
|
|
169
|
+
usage_data[:cache_creation_input_tokens] = cache_creation if cache_creation > 0
|
|
170
|
+
|
|
171
|
+
result = {
|
|
172
|
+
content: content, tool_calls: tool_calls, finish_reason: finish_reason,
|
|
173
|
+
usage: usage_data, raw_api_usage: usage
|
|
174
|
+
}
|
|
175
|
+
result[:reasoning_content] = reasoning_content unless reasoning_content.empty?
|
|
176
|
+
result
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# ── Tool result formatting ────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
# Format tool results into canonical messages to append to @messages.
|
|
182
|
+
# Input: response (canonical, has :tool_calls), tool_results array
|
|
183
|
+
# Output: canonical messages: [{ role: "tool", tool_call_id:, content: }]
|
|
184
|
+
def format_tool_results(response, tool_results)
|
|
185
|
+
results_map = tool_results.each_with_object({}) { |r, h| h[r[:id]] = r }
|
|
186
|
+
|
|
187
|
+
response[:tool_calls].map do |tc|
|
|
188
|
+
result = results_map[tc[:id]]
|
|
189
|
+
{
|
|
190
|
+
role: "tool",
|
|
191
|
+
tool_call_id: tc[:id],
|
|
192
|
+
content: result ? result[:content] : { error: "Tool result missing" }.to_json
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# ── Private helpers ───────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
# Convert a single canonical message to Anthropic API format.
|
|
200
|
+
# @param msg [Hash] canonical message
|
|
201
|
+
# @param _caching_enabled [Boolean] kept for signature compatibility
|
|
202
|
+
# @param strip_thinking_signatures [Boolean] when true, strip `signature`
|
|
203
|
+
# and `data` from thinking blocks — required for third-party endpoints
|
|
204
|
+
# (Kimi /coding, DeepSeek /anthropic, etc.) that cannot re-validate
|
|
205
|
+
# Anthropic-proprietary signatures.
|
|
206
|
+
private_class_method def self.to_api_message(msg, _caching_enabled, strip_thinking_signatures = false)
|
|
207
|
+
role = msg[:role]
|
|
208
|
+
content = msg[:content]
|
|
209
|
+
tool_calls = msg[:tool_calls]
|
|
210
|
+
reasoning_content = msg[:reasoning_content]
|
|
211
|
+
|
|
212
|
+
# Build thinking block from reasoning_content if present.
|
|
213
|
+
# Kimi returns thinking as reasoning_content field, but Anthropic API
|
|
214
|
+
# expects it as a thinking content block. Convert here so the field
|
|
215
|
+
# doesn't leak as an unknown key to the API.
|
|
216
|
+
thinking_block = nil
|
|
217
|
+
if role == "assistant" && reasoning_content.is_a?(String) && !reasoning_content.empty?
|
|
218
|
+
thinking_block = { type: "thinking", thinking: reasoning_content }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# assistant with tool_calls → content blocks with tool_use
|
|
222
|
+
if role == "assistant" && tool_calls&.any?
|
|
223
|
+
blocks = []
|
|
224
|
+
blocks << thinking_block if thinking_block
|
|
225
|
+
blocks << { type: "text", text: content } if content.is_a?(String) && !content.empty?
|
|
226
|
+
if content.is_a?(Array)
|
|
227
|
+
blocks.concat(content_to_blocks(content, strip_thinking_signatures))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
tool_calls.each do |tc|
|
|
231
|
+
func = tc[:function] || tc
|
|
232
|
+
name = func[:name] || tc[:name]
|
|
233
|
+
raw_args = func[:arguments] || tc[:arguments]
|
|
234
|
+
input =
|
|
235
|
+
if raw_args.is_a?(String)
|
|
236
|
+
begin
|
|
237
|
+
JSON.parse(raw_args)
|
|
238
|
+
rescue JSON::ParserError => e
|
|
239
|
+
Octo::Logger.warn("message_format.anthropic.tool_args_parse_failed",
|
|
240
|
+
tool_name: name.to_s,
|
|
241
|
+
tool_call_id: tc[:id].to_s,
|
|
242
|
+
args_len: raw_args.length,
|
|
243
|
+
args_head: raw_args[0, 120],
|
|
244
|
+
error: e.message
|
|
245
|
+
) if defined?(Octo::Logger)
|
|
246
|
+
{}
|
|
247
|
+
end
|
|
248
|
+
else
|
|
249
|
+
raw_args
|
|
250
|
+
end
|
|
251
|
+
blocks << { type: "tool_use", id: tc[:id], name: name, input: input || {} }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
return { role: "assistant", content: blocks }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# canonical tool result (role: "tool") → Anthropic user message with tool_result block
|
|
258
|
+
if role == "tool"
|
|
259
|
+
# Strip any cache_control that Client#apply_message_caching may have
|
|
260
|
+
# embedded INSIDE msg[:content] (it wraps string content as
|
|
261
|
+
# [{type:"text", text:..., cache_control:{...}}]). We hoist that
|
|
262
|
+
# marker up to the tool_result block itself below — that's where
|
|
263
|
+
# Anthropic expects the marker for a tool_result turn.
|
|
264
|
+
#
|
|
265
|
+
# CRITICAL: if we leave cache_control on the inner text block, the
|
|
266
|
+
# tool_result.content shape flips between "string" and
|
|
267
|
+
# "[{text,cache_control}]" depending on whether this message is the
|
|
268
|
+
# current cache breakpoint — which mutates the cached prefix every
|
|
269
|
+
# turn and destroys cache_read hit-rate (the classic "cache_read
|
|
270
|
+
# stuck at tiny number" symptom).
|
|
271
|
+
hoisted_cache_control = nil
|
|
272
|
+
raw_content = msg[:content]
|
|
273
|
+
if raw_content.is_a?(Array) &&
|
|
274
|
+
raw_content.length == 1 &&
|
|
275
|
+
raw_content.first.is_a?(Hash) &&
|
|
276
|
+
raw_content.first[:type] == "text" &&
|
|
277
|
+
raw_content.first[:cache_control]
|
|
278
|
+
hoisted_cache_control = raw_content.first[:cache_control]
|
|
279
|
+
raw_content = raw_content.first[:text]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# If content is an Array of canonical blocks (e.g. image_url + text from file_reader),
|
|
283
|
+
# convert each block to Anthropic format via content_to_blocks.
|
|
284
|
+
# Plain strings pass through unchanged.
|
|
285
|
+
tool_content = if raw_content.is_a?(Array)
|
|
286
|
+
content_to_blocks(raw_content, strip_thinking_signatures)
|
|
287
|
+
else
|
|
288
|
+
raw_content
|
|
289
|
+
end
|
|
290
|
+
block = { type: "tool_result", tool_use_id: msg[:tool_call_id], content: tool_content }
|
|
291
|
+
block[:cache_control] = hoisted_cache_control if hoisted_cache_control
|
|
292
|
+
return { role: "user", content: [block] }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# legacy Anthropic-native tool result already in user+tool_result format — pass through
|
|
296
|
+
if role == "user" && content.is_a?(Array) && content.any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" }
|
|
297
|
+
return { role: "user", content: content }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# regular user/assistant message
|
|
301
|
+
# NOTE: cache_control markers are applied by Client#apply_message_caching before
|
|
302
|
+
# build_request_body is called. We must NOT add extra cache_control here, because:
|
|
303
|
+
# 1. apply_message_caching already placed the marker on the correct breakpoint message.
|
|
304
|
+
# 2. Adding cache_control to every user message causes Anthropic to treat every
|
|
305
|
+
# user message as a cache breakpoint, which invalidates the intended cache boundary
|
|
306
|
+
# and results in cache misses (cache_read=0) every turn.
|
|
307
|
+
blocks = content_to_blocks(content, strip_thinking_signatures)
|
|
308
|
+
# Prepend thinking block for assistant messages with reasoning_content
|
|
309
|
+
blocks.unshift(thinking_block) if thinking_block
|
|
310
|
+
# Anthropic rejects messages with an empty content array — use a placeholder text block.
|
|
311
|
+
blocks = [{ type: "text", text: "..." }] if blocks.empty?
|
|
312
|
+
{ role: role, content: blocks }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Convert content (String or Array) to Anthropic content block array.
|
|
316
|
+
# @param content [String, Array] canonical content
|
|
317
|
+
# @param strip_thinking_signatures [Boolean] when true, strip `signature`
|
|
318
|
+
# and `data` from thinking blocks for third-party endpoints.
|
|
319
|
+
private_class_method def self.content_to_blocks(content, strip_thinking_signatures = false)
|
|
320
|
+
case content
|
|
321
|
+
when String
|
|
322
|
+
# Anthropic rejects blank text blocks — skip empty strings
|
|
323
|
+
return [] if content.empty?
|
|
324
|
+
|
|
325
|
+
[{ type: "text", text: content }]
|
|
326
|
+
when Array
|
|
327
|
+
content.map { |b| normalize_block(b, strip_thinking_signatures) }.compact
|
|
328
|
+
else
|
|
329
|
+
str = content.to_s
|
|
330
|
+
return [] if str.empty?
|
|
331
|
+
|
|
332
|
+
[{ type: "text", text: str }]
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Normalize a single content block to Anthropic format.
|
|
337
|
+
# @param block [Hash] canonical block
|
|
338
|
+
# @param strip_thinking_signatures [Boolean] when true, strip `signature`
|
|
339
|
+
# and `data` from thinking blocks for third-party endpoints.
|
|
340
|
+
private_class_method def self.normalize_block(block, strip_thinking_signatures = false)
|
|
341
|
+
return block unless block.is_a?(Hash)
|
|
342
|
+
|
|
343
|
+
case block[:type]
|
|
344
|
+
when "text"
|
|
345
|
+
# Anthropic rejects blank text blocks — drop them instead of sending { type:"text", text:"" }
|
|
346
|
+
text = block[:text]
|
|
347
|
+
return nil if text.nil? || text.empty?
|
|
348
|
+
|
|
349
|
+
# Preserve cache_control if present (placed by Client#apply_message_caching)
|
|
350
|
+
result = { type: "text", text: text }
|
|
351
|
+
result[:cache_control] = block[:cache_control] if block[:cache_control]
|
|
352
|
+
result
|
|
353
|
+
when "image_url"
|
|
354
|
+
url = block.dig(:image_url, :url) || block[:url]
|
|
355
|
+
url_to_image_block(url)
|
|
356
|
+
when "image"
|
|
357
|
+
block # already Anthropic format
|
|
358
|
+
when "tool_result", "tool_use"
|
|
359
|
+
block # pass through
|
|
360
|
+
when "thinking"
|
|
361
|
+
# Third-party Anthropic-compatible endpoints (Kimi /coding, DeepSeek
|
|
362
|
+
# /anthropic, etc.) return thinking blocks with real signatures that
|
|
363
|
+
# they cannot re-validate on replay. Strip the proprietary fields
|
|
364
|
+
# and emit only the minimal shape {type, thinking} those endpoints
|
|
365
|
+
# accept, while preserving the thinking text for history validation.
|
|
366
|
+
if strip_thinking_signatures
|
|
367
|
+
thinking_text = block[:thinking] || block["thinking"] || ""
|
|
368
|
+
{ type: "thinking", thinking: thinking_text }
|
|
369
|
+
else
|
|
370
|
+
block
|
|
371
|
+
end
|
|
372
|
+
else
|
|
373
|
+
block
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Convert an image URL to Anthropic image block.
|
|
378
|
+
private_class_method def self.url_to_image_block(url)
|
|
379
|
+
return nil unless url
|
|
380
|
+
|
|
381
|
+
if url.start_with?("data:")
|
|
382
|
+
match = url.match(/^data:([^;]+);base64,(.*)$/)
|
|
383
|
+
if match
|
|
384
|
+
{ type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }
|
|
385
|
+
else
|
|
386
|
+
{ type: "image", source: { type: "url", url: url } }
|
|
387
|
+
end
|
|
388
|
+
else
|
|
389
|
+
{ type: "image", source: { type: "url", url: url } }
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Convert OpenAI-style tool definition to Anthropic format.
|
|
394
|
+
private_class_method def self.to_api_tool(tool)
|
|
395
|
+
func = tool[:function] || tool
|
|
396
|
+
{ name: func[:name], description: func[:description], input_schema: func[:parameters] }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Extract plain text from content (String or Array).
|
|
400
|
+
private_class_method def self.extract_text(content)
|
|
401
|
+
case content
|
|
402
|
+
when String then content
|
|
403
|
+
when Array then content.map { |b| b.is_a?(Hash) ? (b[:text] || "") : b.to_s }.join("\n")
|
|
404
|
+
else content.to_s
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|