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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- 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
|