samagotchi 0.2.0

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.
Files changed (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # How `bin/chi` runs a session: attached to a worker (as `--attach` and
5
+ # `--shared` do) or in the plain in-process REPL. With `session.shared` on,
6
+ # plain `chi` runs attached unless it asks for something attached mode
7
+ # can't do yet.
8
+ module LaunchMode
9
+ # Options an attached TUI can't honor: the worker prints nothing, so
10
+ # --verbose is about this process's output. (--model and --no-interrupt
11
+ # reach the worker: a /model command, and no_interrupt on each posted
12
+ # turn; --memory and --mute are session fields the worker reads.)
13
+ REPL_ONLY = { verbose: "--verbose" }.freeze
14
+
15
+ module_function
16
+
17
+ # @param options [Hash] bin/chi's parsed options
18
+ # @param shared_config [Boolean] the session.shared config value
19
+ # @return [Array(Symbol, String)] :attached or :repl, and a note for the
20
+ # user when the config asked for attached mode but didn't get it
21
+ def resolve(options, shared_config:)
22
+ return [:attached, nil] if options[:attach] || options[:shared]
23
+ # --no-shared, or nobody asked for attached mode.
24
+ return [:repl, nil] if options[:shared] == false || !shared_config
25
+ # A one-shot with no REPL: nothing to attach.
26
+ return [:repl, nil] if options[:non_interactive]
27
+
28
+ flag = REPL_ONLY.find { |key, _flag| options[key] }&.last
29
+ return [:repl, "(session.shared: #{flag} runs in a plain REPL)"] if flag
30
+
31
+ [:attached, nil]
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_result"
4
+
5
+ module Samagotchi
6
+ module LLM
7
+ # Interface every model backend implements. The agentic loop lives INSIDE the
8
+ # backend (Option A): a single `complete` call runs generation + tool rounds to
9
+ # completion and returns a finished ModelResult.
10
+ #
11
+ # The signature mirrors KernelLoop#run so any provider backend can forward the
12
+ # streaming seam, Ctrl-C, model override, and tool-output cap unchanged. Any
13
+ # extra surface lives on backend-specific subclasses, never on this interface.
14
+ #
15
+ # The raw-prompt KernelLoop is NativeBackend; the chat loop is ChatLoop.
16
+ class ModelBackend
17
+ def complete(messages:, max_iterations: 100, on_stream_event: nil, cancel_controller: nil,
18
+ model_name: nil, max_tool_output_chars: nil, pending_input: nil)
19
+ raise NotImplementedError, "#{self.class}#complete must be implemented"
20
+ end
21
+ end
22
+
23
+ # Autoloaded: both require this file for their superclass, so a plain
24
+ # require here would be circular.
25
+ autoload :ChatLoop, File.expand_path("chat_loop", __dir__)
26
+ autoload :NativeBackend, File.expand_path("native_backend", __dir__)
27
+ end
28
+ end
@@ -0,0 +1,450 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "backend"
5
+ require_relative "model_result"
6
+ require_relative "errors"
7
+ require_relative "usage"
8
+ require_relative "openai_chat"
9
+ require_relative "native_tool_normalizer"
10
+ require_relative "../kernel_loop"
11
+ require_relative "../context_window"
12
+ require_relative "../context_note"
13
+ require_relative "../tool_runner"
14
+ require_relative "../tool_declarations"
15
+ require_relative "../vision_context"
16
+ require_relative "../log"
17
+
18
+ module Samagotchi
19
+ module LLM
20
+ # The chat loop: the model gets the conversation as chat messages plus
21
+ # function schemas, and calls tools natively. Each iteration is one
22
+ # streamed request through the adapter (OpenAIChat) built from the
23
+ # accumulated conversation; tool calls run through ToolRunner (the same
24
+ # events, hooks and veto as the native loop) and their results go back as
25
+ # tool messages, until the model answers without a tool call or
26
+ # max_iterations is reached.
27
+ #
28
+ # Stream events match the native loop's: :generation_started (with the
29
+ # context window), :generation_chunk per delta, :generation_completed,
30
+ # :generation_retrying, the tool-call events and :pending_input_merged.
31
+ # A chunk carries `thinking:` (reasoning deltas), `text:` (answer deltas)
32
+ # and `content:` (both, like native raw content) plus the raw `payload:`.
33
+ #
34
+ # Cancel closes the request's socket (LLM::HTTP), so it works on any
35
+ # thread; the text streamed so far is kept, marked [interrupted].
36
+ class ChatLoop < ModelBackend
37
+ attr_accessor :adapter
38
+ # The session every request is for; Engine sets it with the adapter.
39
+ # Goes out as a Session-Id header (OpenAIChat#chat).
40
+ attr_accessor :session_id
41
+
42
+ # @param kernel [KernelLoop] tool dispatch, thought stripping, hooks
43
+ # @param adapter [OpenAIChat, nil] the host to talk to; Engine sets it
44
+ # per turn for the effective model's host
45
+ def initialize(kernel:, adapter: nil)
46
+ @kernel = kernel
47
+ @adapter = adapter
48
+ end
49
+
50
+ def provider = :chat
51
+
52
+ def complete(messages:, max_iterations: 100, on_stream_event: nil, cancel_controller: nil,
53
+ model_name: nil, max_tool_output_chars: nil, pending_input: nil)
54
+ raise ArgumentError, "ChatLoop has no adapter" unless @adapter
55
+
56
+ conversation = Array(messages).map(&:dup)
57
+ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input)
58
+ .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
59
+ rescue StandardError => e
60
+ FailedTurn.attach(e, conversation && plain(conversation))
61
+ raise
62
+ end
63
+
64
+ # Tool definitions for the request: the schemas the native prompts are
65
+ # rendered from (the kernel's registry), with the chat-only enums and
66
+ # closed parameters.
67
+ def tool_definitions
68
+ ToolDeclarations.chat_schemas(tools.schemas).map do |schema|
69
+ { type: "function", function: schema.slice(:name, :description, :parameters) }
70
+ end
71
+ end
72
+
73
+ # The kernel's tools; the built-ins for a kernel without a registry.
74
+ def tools
75
+ @kernel.respond_to?(:tools) ? @kernel.tools : Tools::Builtins.default
76
+ end
77
+
78
+ # engine-format conversation -> OpenAI wire messages. Model turns are
79
+ # thought-stripped and carry their tool_calls; tool responses go as tool
80
+ # messages with their tool_call_id; user content goes as parts (a String
81
+ # is wrapped in a text part).
82
+ #
83
+ # The API rejects an assistant tool call without its tool message (and
84
+ # the reverse), so calls and results that don't pair up are sent as
85
+ # text instead: one broken turn must not fail every later request. A
86
+ # tool result without an id (native or older chat history) goes as a
87
+ # user message "[tool results]\n…", valid on every server.
88
+ #
89
+ # Images (+images:+ refs) go as image_url parts after the text. A tool
90
+ # message takes text only, so a run of tool results' images follows it
91
+ # as one user message "[images from tool results]". Images the request
92
+ # leaves out (ImagePlan) become placeholder lines in the text.
93
+ def wire_messages(conversation)
94
+ paired = paired_call_ids(conversation)
95
+ plan = ImagePlan.new(conversation, vision)
96
+ tool_images = []
97
+ wire = []
98
+ conversation.each_with_index do |entry, index|
99
+ items = plan.items(entry, index)
100
+ case entry[:role].to_s
101
+ when "system"
102
+ wire << { role: "system", content: entry[:content].to_s }
103
+ when "user"
104
+ wire << { role: "user", content: parts(entry[:content], items) }
105
+ when "model"
106
+ wire << assistant_message(entry, paired)
107
+ when "tool_response"
108
+ id = entry[:tool_call_id]
109
+ if id && paired.include?(id)
110
+ wire << { role: "tool", content: with_placeholders(entry[:content].to_s, items), tool_call_id: id }
111
+ tool_images.concat(image_parts(items))
112
+ else
113
+ text = "[tool results]\n#{entry[:content]}"
114
+ wire << { role: "user", content: items.empty? ? text : parts(text, items) }
115
+ end
116
+ else
117
+ wire << { role: entry[:role].to_s, content: parts(entry[:content]) }
118
+ end
119
+ next if conversation[index + 1]&.dig(:role).to_s == "tool_response" || tool_images.empty?
120
+
121
+ wire << { role: "user", content: [{ type: "text", text: TOOL_IMAGES_TEXT }, *tool_images] }
122
+ tool_images = []
123
+ end
124
+ wire
125
+ end
126
+
127
+ TOOL_IMAGES_TEXT = "[images from tool results]"
128
+
129
+ # The turn's VisionContext (the Engine sets it on the kernel), or nil.
130
+ def vision
131
+ @kernel.respond_to?(:vision) ? @kernel.vision : nil
132
+ end
133
+
134
+ def strip_model_thought(text)
135
+ @kernel.respond_to?(:strip_model_thought) ? @kernel.strip_model_thought(text) : text
136
+ end
137
+
138
+ def tool_runner
139
+ @tool_runner ||= ToolRunner.new(@kernel)
140
+ end
141
+
142
+ # The window for +model+: the kernel's client probes the server it
143
+ # points at (Engine keeps it on the effective model's host; a local
144
+ # llama.cpp answers /props), then the host's model list, config, env
145
+ # and the default. A remote provider has no /props to probe.
146
+ def context_window(model)
147
+ remote = @adapter.respond_to?(:remote?) && @adapter.remote?
148
+ client = @kernel.client if !remote && @kernel.respond_to?(:client)
149
+ ContextWindow.resolve(client: client, model: model, adapter: @adapter)
150
+ rescue StandardError
151
+ nil
152
+ end
153
+
154
+ # The status line's context value for +used_tokens+ of +window_tokens+
155
+ # (KernelLoop#context_display), or nil.
156
+ def context_display(used_tokens:, window_tokens:)
157
+ return nil unless @kernel.respond_to?(:context_display)
158
+
159
+ @kernel.context_display(used_tokens: used_tokens, window_tokens: window_tokens)
160
+ end
161
+
162
+ # A hook failing must not break the turn (as in ToolRunner).
163
+ def fire_hook(name, event)
164
+ hooks = @kernel.hooks if @kernel.respond_to?(:hooks)
165
+ hooks&.fire(name, event)
166
+ rescue StandardError
167
+ nil
168
+ end
169
+
170
+ # The shape the engine persists: role and content (parts stay an
171
+ # Array), plus a model turn's tool_calls and thinking (the host's
172
+ # reasoning, never sent back), a result's tool_call_id, the image
173
+ # refs of a user message or a tool result, and a plugin tool result's
174
+ # tool_params and tool_labels (the live row's params line and label,
175
+ # never sent back).
176
+ def plain(conversation)
177
+ conversation.map do |entry|
178
+ content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
179
+ message = { role: entry[:role], content: content }
180
+ message[:tool_calls] = entry[:tool_calls] if entry[:tool_calls].is_a?(Array) && !entry[:tool_calls].empty?
181
+ message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
182
+ message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
183
+ message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
184
+ message[:tool_params] = entry[:tool_params] if entry[:tool_params]
185
+ message[:tool_labels] = entry[:tool_labels] if entry[:tool_labels]
186
+ ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
187
+ message
188
+ end
189
+ end
190
+
191
+ private
192
+
193
+ # Ids of the calls whose assistant turn is followed by a tool message
194
+ # for every one of them (before the next non-tool message).
195
+ def paired_call_ids(conversation)
196
+ paired = []
197
+ conversation.each_with_index do |entry, index|
198
+ ids = Array(entry[:tool_calls]).map { |call| call[:id] }.compact
199
+ next if entry[:role].to_s != "model" || ids.empty?
200
+
201
+ answered = conversation[(index + 1)..].take_while { |next_entry| next_entry[:role].to_s == "tool_response" }
202
+ .map { |next_entry| next_entry[:tool_call_id] }
203
+ paired.concat(ids) if (ids - answered).empty?
204
+ end
205
+ paired
206
+ end
207
+
208
+ def assistant_message(entry, paired)
209
+ text = strip_model_thought(entry[:content].to_s)
210
+ calls = Array(entry[:tool_calls])
211
+ return { role: "assistant", content: text } if calls.empty?
212
+
213
+ if calls.all? { |call| paired.include?(call[:id]) }
214
+ { role: "assistant", content: text.empty? ? nil : text,
215
+ tool_calls: calls.map { |call| wire_call(call) } }
216
+ else
217
+ flat = calls.map { |call| "[tool call] #{call[:name]} #{wire_arguments(call[:arguments])}" }
218
+ { role: "assistant", content: [text, *flat].reject(&:empty?).join("\n") }
219
+ end
220
+ end
221
+
222
+ def wire_call(call)
223
+ { id: call[:id], type: "function", function: { name: call[:name].to_s, arguments: wire_arguments(call[:arguments]) } }
224
+ end
225
+
226
+ # The API wants arguments as a JSON string; a raw (invalid) one stays.
227
+ def wire_arguments(arguments)
228
+ arguments.is_a?(String) ? arguments : JSON.generate(arguments || {})
229
+ end
230
+
231
+ # User content as parts: the text first (scrubbed of invalid UTF-8,
232
+ # with a line per image left out), then the images.
233
+ def parts(content, items = [])
234
+ if content.is_a?(Array)
235
+ notes = with_placeholders("", items)
236
+ return content + (notes.empty? ? [] : [{ type: "text", text: notes }]) + image_parts(items)
237
+ end
238
+
239
+ [{ type: "text", text: scrub(with_placeholders(content.to_s, items)) }] + image_parts(items)
240
+ end
241
+
242
+ def image_parts(items)
243
+ items.select(&:sent?).map { |item| { type: "image_url", image_url: { url: item.data } } }
244
+ end
245
+
246
+ def with_placeholders(text, items)
247
+ notes = items.reject(&:sent?).map(&:placeholder)
248
+ notes.empty? ? text : [text, *notes].reject(&:empty?).join("\n")
249
+ end
250
+
251
+ def scrub(text)
252
+ text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
253
+ end
254
+
255
+ # One turn's state: the conversation, the stream sink, usage and tool
256
+ # activity.
257
+ class Run
258
+ def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input)
259
+ @loop = loop
260
+ @conversation = conversation
261
+ @on_stream_event = on_stream_event
262
+ @cancel_controller = cancel_controller
263
+ @model_name = model_name
264
+ @pending_input = pending_input
265
+ @usage = UsageCollector.new
266
+ @tool_activity = []
267
+ # What an estimate counts as the prompt when the server reports no
268
+ # usage: the text, and each image's estimate (not its base64).
269
+ @prompt_text = conversation.sum("") { |entry| entry[:content].to_s }
270
+ @image_tokens = ImagePlan.estimated_tokens(conversation)
271
+ end
272
+
273
+ EMPTY_ANSWER = "(the model returned an empty answer)"
274
+
275
+ def call(max_iterations:, cap:)
276
+ last_text = ""
277
+ exhausted = true
278
+ max_iterations.times do |index|
279
+ iteration = index + 1
280
+ return canceled(iteration, @cancel_controller.reason) if @cancel_controller&.cancelled?
281
+
282
+ inject_pending_input(iteration)
283
+ response, partial = generate(iteration)
284
+ return canceled(iteration, response, partial) if partial
285
+
286
+ last_text = @loop.strip_model_thought(response.text)
287
+ if response.tool_calls.empty?
288
+ # Kept before a merge too: the model answers the merged line
289
+ # knowing what it just said.
290
+ @conversation << with_thinking({ role: "model", content: last_text }, response) unless last_text.empty?
291
+ next if inject_pending_input(iteration, answer: last_text)
292
+
293
+ # Shown, not saved: an empty answer (content "" + stop, seen from
294
+ # a remote host) would otherwise end the turn with nothing.
295
+ last_text = EMPTY_ANSWER if last_text.empty?
296
+ exhausted = false
297
+ break
298
+ end
299
+
300
+ # The calls are kept with the turn (even with no text) so the next
301
+ # request, and a resumed session, can pair them with their results.
302
+ @conversation << with_thinking({ role: "model", content: last_text,
303
+ tool_calls: response.tool_calls.map { |call| { id: call.id, name: call.name, arguments: call.arguments } } },
304
+ response)
305
+ last_text = ""
306
+ dispatch(response.tool_calls, iteration, cap)
307
+ end
308
+ result(last_text, exhausted: exhausted)
309
+ end
310
+
311
+ private
312
+
313
+ # The host's reasoning, kept on the model message as +thinking+ for
314
+ # the web turn view's reload (the whole of it, as the live view
315
+ # shows). Only saved: #assistant_message builds the wire message from
316
+ # content and tool_calls, so it never goes back to the model.
317
+ def with_thinking(message, response)
318
+ reasoning = response.reasoning.to_s
319
+ reasoning.strip.empty? ? message : message.merge(thinking: reasoning)
320
+ end
321
+
322
+ # One streamed request. Returns [response, nil], or [reason, partial
323
+ # text] when it was cancelled.
324
+ def generate(iteration)
325
+ window = @loop.context_window(@model_name)
326
+ emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
327
+ context_window_source: window&.source)
328
+ @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
329
+ streamed = +""
330
+ response = @loop.adapter.chat(
331
+ messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
332
+ cancel_controller: @cancel_controller, session_id: @loop.session_id,
333
+ on_delta: lambda { |content:, reasoning:, payload:|
334
+ streamed << content
335
+ emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
336
+ thinking: reasoning, payload: payload)
337
+ },
338
+ on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
339
+ )
340
+ record_context_status(response.usage, window)
341
+ emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
342
+ thinking_chars: response.reasoning.to_s.length, served_model: response.model,
343
+ requested_model: @model_name)
344
+ dump_response(response, iteration)
345
+ @loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
346
+ messages: @conversation.map(&:dup).freeze })
347
+ [response, nil]
348
+ rescue RequestCancelled => e
349
+ [e.reason, streamed]
350
+ end
351
+
352
+ # The status line's value from the server's counts for this request
353
+ # (prompt + answer); without them the last value stays.
354
+ def record_context_status(usage, window)
355
+ return unless usage.source == :server && window
356
+
357
+ display = @loop.context_display(used_tokens: usage.total_tokens, window_tokens: window.tokens)
358
+ @context_status = display if display
359
+ end
360
+
361
+ # The model's answer at debug level, as the native loop dumps its
362
+ # raw response (the tool calls and results come through the kernel's
363
+ # dispatch dumps).
364
+ def dump_response(response, iteration)
365
+ return unless Log.level?(:debug)
366
+
367
+ thinking = response.reasoning.to_s
368
+ text = thinking.empty? ? response.text.to_s : "<thinking>\n#{thinking}\n</thinking>\n#{response.text}"
369
+ calls = Array(response.tool_calls).map(&:name)
370
+ Log.debug(:model, "response", payload: text, model: @model_name, iteration: iteration,
371
+ served_model: response.model, tool_calls: calls.empty? ? nil : calls.join(","))
372
+ end
373
+
374
+ def dispatch(tool_calls, iteration, cap)
375
+ emit(type: :tool_dispatch_started, iteration: iteration, call_count: tool_calls.length)
376
+ tool_calls.each_with_index do |tool_call, index|
377
+ call = NativeToolNormalizer.normalize(tool_call)
378
+ run = @loop.tool_runner.run(call, iteration: iteration, call_index: index + 1, call_count: tool_calls.length,
379
+ on_stream_event: @on_stream_event, max_tool_output_chars: cap)
380
+ @tool_activity << run[:activity] if run[:activity]
381
+ # The chat loop feeds the model the capped output (native feeds
382
+ # the full one; D-P2-6).
383
+ entry = { role: "tool_response", content: run[:capped_output], tool_call_id: tool_call.id }
384
+ entry[:images] = run[:images] if run[:images]&.any?
385
+ # A plugin tool's params line, for the web's reload; never sent.
386
+ entry[:tool_params] = run[:shown_params] if run[:shown_params]
387
+ entry[:tool_labels] = run[:shown_label] if run[:shown_label]
388
+ @conversation << entry
389
+ end
390
+ emit(type: :tool_dispatch_completed, iteration: iteration, call_count: tool_calls.length)
391
+ end
392
+
393
+ # Queued steering joins the conversation as one user message.
394
+ # Returns true when there was any. After a cancel it stays queued, so
395
+ # it runs as the next turn instead of dying with this one. +answer+ is
396
+ # the answer the merge follows, for the UIs.
397
+ def inject_pending_input(iteration, answer: nil)
398
+ return false unless @pending_input
399
+ return false if @cancel_controller&.cancelled?
400
+
401
+ lines = begin
402
+ @pending_input.call
403
+ rescue StandardError
404
+ nil
405
+ end
406
+ return false if lines.nil? || lines.empty?
407
+
408
+ content = lines.map { |line| line.to_s.strip }.reject(&:empty?).join("\n\n")
409
+ return false if content.empty?
410
+
411
+ @conversation << { role: "user", content: content }
412
+ emit(type: :pending_input_merged, iteration: iteration, count: lines.length, content: content,
413
+ answer: answer.to_s.empty? ? nil : answer)
414
+ true
415
+ end
416
+
417
+ # The text streamed before the cancel stays, marked [interrupted],
418
+ # as the native loop's salvage does.
419
+ def canceled(iteration, reason, partial = "")
420
+ emit(type: :generation_cancelled, iteration: iteration, reason: reason)
421
+ visible = @loop.strip_model_thought(partial.to_s).strip
422
+ @conversation << { role: "model", content: "#{visible}\n[interrupted]", interrupted: true } unless visible.empty?
423
+ conversation = @loop.plain(@conversation)
424
+ conversation.last[:interrupted] = true unless visible.empty?
425
+ ModelResult.new(text: "", provider: :chat, conversation: conversation, canceled: true,
426
+ cancellation_reason: reason, tool_activity: @tool_activity, usage: usage,
427
+ context_status: @context_status)
428
+ end
429
+
430
+ def result(text, exhausted:)
431
+ ModelResult.new(text: text, provider: :chat, conversation: @loop.plain(@conversation), exhausted: exhausted,
432
+ tool_activity: @tool_activity, usage: usage, empty_answer: text == EMPTY_ANSWER,
433
+ context_status: @context_status)
434
+ end
435
+
436
+ def usage
437
+ @usage.usage(prompt_text: @prompt_text, extra_prompt_tokens: @image_tokens)
438
+ end
439
+
440
+ def emit(event)
441
+ @usage.observe(event)
442
+ @on_stream_event&.call(event)
443
+ rescue StandardError
444
+ nil
445
+ end
446
+ end
447
+ private_constant :Run
448
+ end
449
+ end
450
+ end