samagotchi 0.155.0 → 0.157.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 +4 -4
- data/CHANGELOG.md +57 -1
- data/bin/chi +2 -1
- data/docs/architecture.md +22 -13
- data/docs/bundles.md +3 -1
- data/docs/cli.md +9 -8
- data/docs/configuration.md +36 -4
- data/docs/guardrails.md +9 -5
- data/docs/hooks.md +11 -5
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/attached-context.md +3 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bridge.md +83 -6
- data/docs/internals/bundles.md +28 -18
- data/docs/internals/context-telemetry.md +4 -3
- data/docs/internals/engine.md +641 -937
- data/docs/internals/llm-context-forget.md +8 -3
- data/docs/internals/objectives.md +2 -2
- data/docs/internals/participants.md +3 -2
- data/docs/internals/prompt-caching.md +4 -1
- data/docs/internals/remote-sessions.md +8 -3
- data/docs/internals/web-frontend.md +3 -2
- data/docs/internals/working-set.md +2 -2
- data/docs/memory.md +13 -2
- data/docs/plugins.md +35 -2
- data/docs/sessions.md +11 -11
- data/docs/sub-agent.md +17 -2
- data/docs/system-one.md +3 -2
- data/docs/testing.md +53 -2
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +4 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +3 -2
- data/lib/samagotchi/bridge/card_places.rb +10 -4
- data/lib/samagotchi/bridge/card_store.rb +3 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
- data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
- data/lib/samagotchi/bridge.rb +31 -6
- data/lib/samagotchi/broadcast/recipients.rb +8 -8
- data/lib/samagotchi/broadcast/triage_model.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +3 -3
- data/lib/samagotchi/bundle_command/check.rb +18 -0
- data/lib/samagotchi/bundle_command/try/sandbox.rb +2 -2
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +5 -2
- data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
- data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
- data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/child_move.rb +13 -57
- data/lib/samagotchi/child_reports.rb +2 -2
- data/lib/samagotchi/children_status.rb +15 -21
- data/lib/samagotchi/cli/parent_wait.rb +9 -23
- data/lib/samagotchi/config.rb +20 -112
- data/lib/samagotchi/context_command.rb +1 -2
- data/lib/samagotchi/context_sources.rb +5 -4
- data/lib/samagotchi/context_window.rb +9 -12
- data/lib/samagotchi/desktop/macos.rb +1 -1
- data/lib/samagotchi/effective_model.rb +57 -22
- data/lib/samagotchi/engine/context_judge.rb +19 -11
- data/lib/samagotchi/engine/messages.rb +1 -1
- data/lib/samagotchi/engine/models.rb +5 -2
- data/lib/samagotchi/engine/objectives.rb +29 -11
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/recap.rb +8 -4
- data/lib/samagotchi/engine/side_requests.rb +21 -11
- data/lib/samagotchi/engine/toolkit.rb +10 -9
- data/lib/samagotchi/engine/turn_end.rb +1 -1
- data/lib/samagotchi/engine/turn_helpers.rb +7 -7
- data/lib/samagotchi/engine/verify.rb +1 -1
- data/lib/samagotchi/engine.rb +43 -12
- data/lib/samagotchi/events.rb +3 -2
- data/lib/samagotchi/extension_load.rb +6 -3
- data/lib/samagotchi/guardrails/targets.rb +24 -9
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/host_address.rb +52 -0
- data/lib/samagotchi/host_config.rb +72 -1
- data/lib/samagotchi/host_model.rb +2 -6
- data/lib/samagotchi/host_registry.rb +58 -46
- data/lib/samagotchi/kernel_loop.rb +53 -124
- data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
- data/lib/samagotchi/llm/chat_loop.rb +40 -21
- data/lib/samagotchi/llm/native_backend.rb +1 -1
- data/lib/samagotchi/llm_context_apply.rb +54 -1
- data/lib/samagotchi/llm_context_notice.rb +1 -1
- data/lib/samagotchi/llm_context_strategy.rb +29 -67
- data/lib/samagotchi/llm_context_view.rb +9 -0
- data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
- data/lib/samagotchi/memory_bundle/builder.rb +2 -1
- data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
- data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
- data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
- data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
- data/lib/samagotchi/memory_bundle/installer.rb +185 -169
- data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
- data/lib/samagotchi/memory_bundle/merger.rb +69 -65
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
- data/lib/samagotchi/memory_bundle/profile.rb +5 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
- data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
- data/lib/samagotchi/memory_bundle/status.rb +14 -20
- data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
- data/lib/samagotchi/memory_bundle/trash.rb +23 -8
- data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
- data/lib/samagotchi/memory_usage.rb +1 -1
- data/lib/samagotchi/model_lookup.rb +75 -0
- data/lib/samagotchi/model_price.rb +4 -10
- data/lib/samagotchi/model_profile.rb +7 -6
- data/lib/samagotchi/model_settings.rb +37 -2
- data/lib/samagotchi/models_command.rb +1 -1
- data/lib/samagotchi/note_command.rb +2 -3
- data/lib/samagotchi/objectives_audit.rb +4 -3
- data/lib/samagotchi/participant_mode.rb +2 -1
- data/lib/samagotchi/participants.rb +2 -2
- data/lib/samagotchi/plugin/api.rb +41 -6
- data/lib/samagotchi/plugin/context.rb +1 -1
- data/lib/samagotchi/plugin/loader.rb +33 -10
- data/lib/samagotchi/plugin/sessions.rb +3 -6
- data/lib/samagotchi/plugin/tool_spec.rb +5 -3
- data/lib/samagotchi/project_env.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +14 -20
- data/lib/samagotchi/recap_place.rb +27 -0
- data/lib/samagotchi/reminder_carry.rb +1 -1
- data/lib/samagotchi/reply_wait/liveness.rb +38 -0
- data/lib/samagotchi/reply_wait/poll.rb +44 -15
- data/lib/samagotchi/reply_wait/target.rb +113 -0
- data/lib/samagotchi/reply_wait.rb +22 -2
- data/lib/samagotchi/reprefill_meter.rb +45 -0
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/sampling_settings.rb +9 -16
- data/lib/samagotchi/self_report.rb +36 -37
- data/lib/samagotchi/send_command.rb +17 -23
- data/lib/samagotchi/session.rb +370 -445
- data/lib/samagotchi/session_access/local.rb +12 -11
- data/lib/samagotchi/session_access/remote.rb +22 -6
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +7 -6
- data/lib/samagotchi/session_continue.rb +6 -5
- data/lib/samagotchi/session_delete_command.rb +1 -1
- data/lib/samagotchi/session_field.rb +133 -0
- data/lib/samagotchi/session_inbox.rb +49 -1
- data/lib/samagotchi/session_manager/delivery.rb +254 -0
- data/lib/samagotchi/session_manager/emptiness.rb +139 -0
- data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
- data/lib/samagotchi/session_manager/listing.rb +118 -0
- data/lib/samagotchi/session_manager/removal.rb +190 -0
- data/lib/samagotchi/session_manager/spawn.rb +242 -0
- data/lib/samagotchi/session_manager/worker_command.rb +157 -0
- data/lib/samagotchi/session_manager.rb +28 -1541
- data/lib/samagotchi/session_metrics.rb +8 -5
- data/lib/samagotchi/session_paths.rb +57 -0
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/session_row.rb +200 -0
- data/lib/samagotchi/session_setup.rb +15 -12
- data/lib/samagotchi/sessions_command.rb +11 -129
- data/lib/samagotchi/share_command.rb +1 -1
- data/lib/samagotchi/starting_marker.rb +65 -0
- data/lib/samagotchi/steer_cut_after.rb +7 -14
- data/lib/samagotchi/system_one/recorder.rb +49 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
- data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
- data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +5 -7
- data/lib/samagotchi/testing/install.rb +2 -1
- data/lib/samagotchi/testing/sessions.rb +1 -1
- data/lib/samagotchi/thinking.rb +16 -21
- data/lib/samagotchi/token_usage.rb +1 -1
- data/lib/samagotchi/tool_activity.rb +10 -4
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tool_output_cut.rb +17 -0
- data/lib/samagotchi/tool_response.rb +5 -3
- data/lib/samagotchi/tool_runner.rb +25 -5
- data/lib/samagotchi/toolkit_command.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +55 -45
- data/lib/samagotchi/tools/delegate_relay.rb +5 -5
- data/lib/samagotchi/tools/delegate_result.rb +5 -5
- data/lib/samagotchi/tools/delegate_wait.rb +41 -17
- data/lib/samagotchi/tools/index_hint.rb +14 -0
- data/lib/samagotchi/tools/list_sessions.rb +7 -7
- data/lib/samagotchi/tools/registry.rb +28 -11
- data/lib/samagotchi/tools/send_note.rb +4 -6
- data/lib/samagotchi/tools/tool_index.rb +52 -51
- data/lib/samagotchi/tools/tool_surface.rb +22 -8
- data/lib/samagotchi/turn_context.rb +2 -1
- data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
- data/lib/samagotchi/turn_stages/objectives.rb +56 -52
- data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
- data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
- data/lib/samagotchi/turn_stages/warmup.rb +14 -15
- data/lib/samagotchi/update_command.rb +11 -7
- data/lib/samagotchi/verify_fresh.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +11 -14
- data/lib/samagotchi/web/app.rb +45 -28
- data/lib/samagotchi/web/message_display.rb +57 -7
- data/lib/samagotchi/web/participant_access.rb +3 -3
- data/lib/samagotchi/web/public/activity.js +16 -7
- data/lib/samagotchi/web/public/app.js +29 -8
- data/lib/samagotchi/web/public/bubbles.js +8 -3
- data/lib/samagotchi/web/public/cards_place.js +15 -0
- data/lib/samagotchi/web/public/commands.js +5 -1
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/history_shape.js +220 -0
- data/lib/samagotchi/web/public/recap_stamp.js +17 -0
- data/lib/samagotchi/web/public/reload_check.js +71 -0
- data/lib/samagotchi/web/public/stage_model.js +7 -5
- data/lib/samagotchi/web/public/stage_view.js +7 -3
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +43 -2
- data/lib/samagotchi/web/public/turn_block.js +215 -30
- data/lib/samagotchi/web/public/turn_drawing.js +43 -0
- data/lib/samagotchi/web/public/turn_events.js +10 -1
- data/lib/samagotchi/web/public/turn_pieces.js +29 -0
- data/lib/samagotchi/web/reload_check_route.rb +95 -0
- data/lib/samagotchi/web/server.rb +4 -2
- data/lib/samagotchi/web/session_access_routes.rb +6 -6
- data/lib/samagotchi/web/session_control.rb +4 -4
- data/lib/samagotchi/web/session_create.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +9 -17
- data/lib/samagotchi/web/session_show.rb +67 -5
- data/lib/samagotchi/web/session_summary.rb +39 -28
- data/lib/samagotchi/worker.rb +83 -32
- data/lib/samagotchi/worker_boot.rb +184 -0
- data/lib/samagotchi/worker_link.rb +3 -0
- data/lib/samagotchi/worker_signals.rb +1 -1
- data/lib/samagotchi/worker_task_wakes.rb +2 -2
- metadata +32 -4
- data/lib/samagotchi/send_command/wait_target.rb +0 -85
data/docs/internals/engine.md
CHANGED
|
@@ -13,1069 +13,773 @@ Engine#run_turn → backend.complete(..., context:) → KernelLoop#run (native)
|
|
|
13
13
|
→ repeat until the model answers without tool calls, or the iteration limit
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
`context` is the turn's `TurnContext` (`lib/samagotchi/turn_context.rb`, see "Turn and TurnContext"
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
`context` is the turn's `TurnContext` (`lib/samagotchi/turn_context.rb`, see "Turn and TurnContext"): every step
|
|
17
|
+
of the tool path takes it as an argument; none of them reads the turn from the kernel, the wiring or the Engine's
|
|
18
|
+
ivars.
|
|
19
19
|
|
|
20
20
|
## Quick reference: the turn lifecycle
|
|
21
21
|
|
|
22
|
+
The skeleton is `Engine#run_turn` in `engine.rb`; read it for the order. Each step and what it owns:
|
|
23
|
+
|
|
22
24
|
```
|
|
23
|
-
Engine#run_turn(session, prompt, on_event:,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
├──
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
│ # the TurnContext built whole first (new_turn_context: GuardrailWiring#begin_turn(origin, session:)
|
|
33
|
-
│ # → TurnStart {git (a fresh GitInfo), participant (participants.json), mode}; id: the caller's
|
|
34
|
-
│ # id:, else a new uuid; cwd, session_dir, state_dir; settings nil until generate),
|
|
35
|
-
│ # then TurnState#begin!(context:, controller:, sink:, input_waiting:): the context published with
|
|
36
|
-
│ # the running flag in one step (a new CancellationController when none is given; input_waiting:
|
|
37
|
-
│ # the caller's peek, Engine#input_waiting?, which task_wait, delegate_result's wait and execute's
|
|
38
|
-
│ # early hand-over poll), a recap in flight invalidated
|
|
39
|
-
│ # Returns a Turn {context, prompt (nil on a continue), continue, on_event, started_at, resumed,
|
|
40
|
-
│ # resume_from, wall (MessageTime.now)}; turn.id/session/origin/controller read
|
|
41
|
-
│ # the context
|
|
42
|
-
│
|
|
43
|
-
├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
|
|
44
|
-
│
|
|
45
|
-
├── prepare_turn(turn, images) # its skeleton; the stages are in "Turn stages" below
|
|
46
|
-
│ # emit :turn_started {session_id, prompt, turn_id, at (the turn's wall time, Turn#wall: read once,
|
|
47
|
-
│ # the user message's and the reminders' stamp too), continue: true (only on a continue),
|
|
48
|
-
│ # resumed: true (only on a step-limit Continue), images (only when there are any),
|
|
49
|
-
│ # cut_after (the model's cut wait, s)}; an image error raised after it
|
|
50
|
-
│ @toolkit_suggestion.start(turn) # before the probes: its latency hides behind them
|
|
51
|
-
│ refresh_profile! # may ask the server's /props once
|
|
52
|
-
│ turn.prepared_settings = turn_settings(session); refuse_images!
|
|
53
|
-
│ # (LLM::TurnSettings: vision, sampling, thinking from one EffectiveModel; model_name, loads_tools …
|
|
54
|
-
│ # come in generate, after the hooks; ThinkingNotices#turn_started says a level the host can't do)
|
|
55
|
-
│ STOP CHECK A → @toolkit_suggestion.abandon(turn, :canceled); return false
|
|
56
|
-
│ announce_guardrail_failures; start_init_tasks!; await_init_tasks; apply_staged_tools!
|
|
57
|
-
│ @toolkit_suggestion.settle(turn) # on mode: the turn joins the ask, before anything is declared
|
|
58
|
-
│ @context_judge.start(turn) # owner turns only; its asks run beside the turn
|
|
59
|
-
│ @objectives_stage.incoming(turn) # System 1's objectives audit, owner and human prompts only
|
|
60
|
-
│ apply_toolkit!(session, on_event) # Registry#toolkit, ToolIndex#refresh! for find_tools' chi
|
|
61
|
-
│ # line, a prompt rebuild when it changed; a warn per plugin tool that arrived mid-session with
|
|
62
|
-
│ # nothing to reach it (Registry#stranded_names)
|
|
63
|
-
│ refresh_participants!(session)
|
|
64
|
-
│ STOP CHECK B → return false # before the hooks run or a reminder is used up
|
|
65
|
-
│ :session_start hook (the Engine's first turn), then :before_turn (a frozen copy of the history,
|
|
66
|
-
│ # the prompt, resumed)
|
|
67
|
-
│ turn_messages(turn, image_refs) # system head + history + due reminders (:reminder_injected)
|
|
68
|
-
│ # + the user message + the plugins' staged tool loads (ctx.load_tools: a kind "tool_change" system
|
|
69
|
-
│ # entry per bundle, only when the turn's host takes tool changes; #append_tool_loads)
|
|
70
|
-
│ ensure: @toolkit_suggestion.abandon(turn, :failed) # nothing once the ask was settled;
|
|
71
|
-
│ # then @toolkit_suggestion.finish(turn): the stage lets the turn and its Pending go
|
|
72
|
-
│ # Returns false at a STOP CHECK (stopped_before_model ends the turn canceled), else true
|
|
73
|
-
│
|
|
74
|
-
├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
|
|
75
|
-
│ # one EffectiveModel (after the hooks, which may switch the model): sync_kernel_client!, then
|
|
76
|
-
│ # turn.context = turn.context.with(settings: turn.prepared_settings with model_name,
|
|
77
|
-
│ # window_setting, llm_context (the session's, the model's/host's, else llm_context.strategy),
|
|
78
|
-
│ # price (hosts.<name>.models.<id>.price) and loads_tools, model_key/fallback_model_key (the
|
|
79
|
-
│ # memory overlay keys) and model_name (the gate's rules' models:), all frozen now),
|
|
80
|
-
│ # published by TurnState#replace_context
|
|
81
|
-
│ # turn.limit = max_iterations or IterationLimit.for
|
|
82
|
-
│ # backend(model).complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input),
|
|
83
|
-
│ # context: turn.context)
|
|
84
|
-
│ # Returns LLM::ModelResult (text, conversation, canceled?, exhausted?, tool_activity,
|
|
85
|
-
│ # context_status, empty_steps, empty_retries)
|
|
86
|
-
│
|
|
87
|
-
├── publish_used_memories(session, on_event) # :used_memories_updated when there are any
|
|
88
|
-
│
|
|
89
|
-
├── complete_turn(turn, result)
|
|
90
|
-
│ # end_turn(turn, "completed" | "canceled"): under the event lock the session gets the kept
|
|
91
|
-
│ # messages, goes IDLE, records last_turn (enqueued_id: the turn's input, from its origin;
|
|
92
|
-
│ # a continue turn's origin carries its offer turn's, ContinueOffer#run_continue_turn),
|
|
93
|
-
│ # and :turn_completed {result, turn_summary, display_pending} or :turn_canceled
|
|
94
|
-
│ # {cancellation_reason, cancelled_by, duration_ms} is emitted; before the replace,
|
|
95
|
-
│ # @verify_anchors.stamp_opening puts a Verify turn's `verify` and a completed Revise turn's
|
|
96
|
-
│ # `revises` (re-targeted to its stack's top) on the opening message, metadata never sent,
|
|
97
|
-
│ # @verify_anchors.record_verdict adds a completed Verify turn's verdict, the working set and
|
|
98
|
-
│ # @objectives_stage.announce(session) go out in the same step, and every end event carries
|
|
99
|
-
│ # `turn_id`, :turn_completed `verify` / `verdict` / `revises` when there are
|
|
100
|
-
│ # then @objectives_stage.outgoing(turn, result) (not for a canceled or exhausted turn)
|
|
101
|
-
│ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter,
|
|
102
|
-
│ # refs: the AnswerRefs adder)
|
|
103
|
-
│ # store_answer_display (one replace of the answer: `display` and/or `display_refs`;
|
|
104
|
-
│ # :answer_display {display, refs: N} to the observers when a hook changed the answer or
|
|
105
|
-
│ # attached refs, or display: nil when the web was told to wait for it)
|
|
106
|
-
│ # then the :session_end hook
|
|
107
|
-
│
|
|
108
|
-
├── @turn_warmup.after(turn, result)
|
|
109
|
-
│ # local llama.cpp on the native loop only: prefills the next turn's prompt while the user
|
|
110
|
-
│ # reads (PromptWarmup; see prompt-caching.md, "The turn-end warm-up"). Never fails the turn.
|
|
111
|
-
│
|
|
112
|
-
└── ensure: release_turn(probe_cancel_before)
|
|
113
|
-
# TurnState#finish! (turn flag off, controller, sink and steers cleared; the context kept as
|
|
114
|
-
# the last one, Engine#last_turn_context, with the last settings when it never got its own)
|
|
115
|
-
# SteerCut#finished! (its GenerationPhase finished, its WaitingSteer cleared)
|
|
116
|
-
# probe cancel swapped back
|
|
117
|
-
# the context window cache dropped, so the next turn asks the server again
|
|
118
|
-
# steers left logged as dropped, activity recorded, turn-scoped hooks cleared
|
|
119
|
-
# then @context_judge.show_cards_after_turn (after the flag is released), then
|
|
120
|
-
# @objectives_stage.finally(turn): a completed turn's proposals shown, any other's dropped
|
|
25
|
+
Engine#run_turn(session, prompt, on_event:, …, origin:, continue:, resumed:, resume_from:, verify:, revises:, …)
|
|
26
|
+
├── begin_turn → Turn (the TurnContext published with the running flag)
|
|
27
|
+
├── Client.swap_probe_cancel (a Stop cuts this turn's /props probes)
|
|
28
|
+
├── prepare_turn → false at a STOP CHECK (stopped_before_model), else true
|
|
29
|
+
├── generate → LLM::ModelResult
|
|
30
|
+
├── publish_used_memories
|
|
31
|
+
├── complete_turn
|
|
32
|
+
├── @turn_warmup.after
|
|
33
|
+
└── ensure: release_turn, @context_judge.show_cards_after_turn, show_objective_proposals
|
|
121
34
|
```
|
|
122
35
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
A
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
`
|
|
36
|
+
| Step | Where | What it owns |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `begin_turn` | `engine.rb` | A Verify turn's claim checked (`@verify_anchors.verified`, the sessions dir given); `session.status = RUNNING`; used memories absorbed; the `TurnContext` built **whole** (`new_turn_context`: `GuardrailWiring#begin_turn` gives git, participant and mode; `id:` the caller's or a new uuid; `settings` nil until `generate`), then `TurnState#begin!` publishes it with the controller (a new `CancellationController` when none is given), sink and `input_waiting:` peek in one step; a recap in flight invalidated. `run_turn` sets `turn.resumed` / `turn.resume_from` only on a continue. |
|
|
39
|
+
| `prepare_turn` | `engine.rb` | `:turn_started` (an image error raised after it); the toolkit suggestion's `start`; `refresh_profile!`; `turn.prepared_settings = turn_settings(session)` and `refuse_images!`; **STOP CHECK A** (abandons the suggestion `:canceled`); `announce_guardrail_failures`, `start_init_tasks!` / `await_init_tasks`, `apply_staged_tools!`; the suggestion's `settle`, the judge's `start`, the objectives' `incoming`; `apply_toolkit!` (`Registry#toolkit`, `ToolIndex#refresh!`, a prompt rebuild when it changed, a warn per stranded plugin tool); `refresh_participants!`; **STOP CHECK B** (before the hooks run or a reminder is used up); `:session_start` (first turn) and `:before_turn` hooks; `turn_messages`. Its ensure abandons the suggestion `:failed` (nothing once settled) and calls `finish`. |
|
|
40
|
+
| `turn_messages` | `engine.rb` | System head + history + due reminders (`:reminder_injected`; none in a participant's turn) + the user message (not on a continue) + the plugins' staged tool loads (`append_tool_loads`, `engine/toolkit.rb`). `turn.messages` is set first and grown in place, so a failure meanwhile keeps what is there. |
|
|
41
|
+
| `generate` | `engine.rb` | One `EffectiveModel` **after the hooks** (which may switch the model): `sync_kernel_client!`, the settings completed (`model_name`, `window_setting`, `llm_context`, `price`, `loads_tools`), the turn's `ToolSurface` built, `turn.context.with(…)` published by `TurnState#replace_context`; `turn.limit` (`IterationLimit.for`); `backend(model).complete(…, pending_input: turn_drain(pending_input), reasoning_since:, context:)`. |
|
|
42
|
+
| `complete_turn` / `end_turn` | `engine/turn_end.rb`, `engine.rb` | See "Turn ends" below. |
|
|
43
|
+
| `@turn_warmup.after` | `TurnStages::Warmup` | Local llama.cpp, native loop only: prefills the next turn's prompt (prompt-caching.md, "The turn-end warm-up"). Never fails the turn. |
|
|
44
|
+
| `release_turn` | `engine/turn_end.rb` | `TurnState#finish!`, `SteerCut#finished!`, probe cancel swapped back, the context window cache dropped (the next turn asks the server again), steers left logged as dropped, activity recorded, turn-scoped hooks cleared. Then, after the flag is released, the judge's cards and `show_objective_proposals` (the objectives' `finally`: a completed turn's proposal cards shown, any other's dropped). |
|
|
45
|
+
|
|
46
|
+
`Engine::Turn` (in `engine.rb`) carries the lifecycle fields; `turn.id`, `session`, `origin` and `controller` read
|
|
47
|
+
its context.
|
|
48
|
+
|
|
49
|
+
**Turn ends.** `end_turn(turn, outcome)` does one step under the event lock: the session gets the kept messages,
|
|
50
|
+
goes IDLE, records `last_turn` (its `enqueued_id` from the origin; a continue turn's origin carries its offer
|
|
51
|
+
turn's, `ContinueOffer#run_continue_turn`), the working set and the objectives' view (`announce_objectives`) go out, and the end
|
|
52
|
+
event is emitted (`:turn_completed` / `:turn_canceled`, every one carrying `turn_id`). Before the replace,
|
|
53
|
+
`@verify_anchors.stamp_opening` puts `verify` / `revises` on the opening message (metadata, never sent) and
|
|
54
|
+
`record_verdict` adds a Verify turn's verdict. When the turn keeps messages, the end event names their range,
|
|
55
|
+
`kept_from` (`turn.history_size`, noted in `turn_messages` right after `ContextNote.with_system_head`) and `kept_to`
|
|
56
|
+
(`kept.size`): display-side only, no message gains a key. `complete_turn` then runs `@objectives_stage.outgoing` (not for a
|
|
57
|
+
canceled or exhausted turn), the `:after_turn` hook (with `present:` and `refs:`, see "Hooks"),
|
|
58
|
+
`store_answer_display` (one replace; `:answer_display` to the observers when a hook changed the answer or attached
|
|
59
|
+
refs) and the `:session_end` hook. `TurnEndings` (`turn_endings.rb`) holds the pure builders (`kept_messages`,
|
|
60
|
+
`failed_messages`, `ctrl_c_messages`, `failed_event`, `turn_summary`, `stop_facts`); the Engine forwards to it.
|
|
130
61
|
|
|
131
|
-
|
|
132
|
-
|---|---|---|---|
|
|
133
|
-
| `TurnStages::ToolkitSuggestion` (ToolkitSuggest's ask: audit and on mode, one eligibility, a copied snapshot, SideJudgments' `:toolkit` kind) | `side_judgments`, `host_registry`, `toolkit_binding`, `tools`, `state_dir`, `recording`, `runs` | `start(turn)` before `refresh_profile!`; `settle(turn)` after `apply_staged_tools!` (on mode: waits up to System 1's timeout from its start, or a Stop, which wins over an answer already in and cuts the ask through `Pending#cancel_controller`; a pick ≥ threshold → `ToolkitBinding#suggest`); `abandon(turn, why)` at STOP CHECK A (`:canceled`) and in the ensure (`:failed`), then `finish(turn)` there; `warm!(session)` / `stop_warmup` (Worker start, `shutdown`) | the turn's `ToolkitSuggest::Pending` while it prepares (turn thread only; another turn's is never touched; dropped at `finish`), the warm-up thread |
|
|
134
|
-
| `TurnStages::ContextJudge` (the attached-context candidate judge: [attached-context.md](attached-context.md), [context.md](../context.md) "The judge") | `side_judgments`, `host_registry`, `state_dir`, `recording`, `session`, `messages`, `turn_running`, `show_card` | `start(turn)` after `settle`, before `apply_toolkit!` (owner turns only: `ContextCandidatesLog#look`, then unless `context.candidates.mode` is off the due asks on SideJudgments' `:context` kind); `late_candidates` (a plugin's late proposal); `show_cards_after_turn` from `run_turn`'s ensure, after `release_turn` | the cards an answer brought while a turn ran, under their mutex |
|
|
135
|
-
| `TurnStages::Objectives` (System 1's objectives audit, ObjectivesAudit; the view; the model's proposals: [objectives.md](objectives.md)) | `side_judgments`, `host_registry`, `state_dir`, `recording`, `runs`, `synchronize`, `announce`, `running`, `show_card` | `incoming(turn)` after the judge's `start`; `announce(session)` inside `end_turn`'s event step (and `Engine#announce_objectives` between turns); `outgoing(turn, result)` in `complete_turn`, after the end event and before `:after_turn`; `note(session, note)` (`add_context_note`); `propose(proposal)` (`Engine#propose_objective`); `finally(turn)` in `run_turn`'s ensure, after the judge's cards | the turn's proposals under their mutex (drained at `finally`), the last view announced, the audit |
|
|
136
|
-
| `TurnStages::Warmup` (the turn-end warm-up, PromptWarmup: [prompt-caching.md](prompt-caching.md), "The turn-end warm-up") | `prompt_warmup`, `kernel`, `turn_state`, `model_settings`, `model`, `system_prompt`, `reminders_due`, `next_turn_waiting` | `after(turn, result)` after `complete_turn` (reads `turn.context.settings`) | none (the PromptWarmup keeps its pin and thread) |
|
|
137
|
-
| `TurnStages::VerifyAnchors` (Verify and Revise anchors) | `state_dir` | `verified(session, anchor)` in `begin_turn`; `stamp_opening(messages, turn, outcome)` and `record_verdict(turn, outcome)` inside `end_turn`'s event step | none |
|
|
62
|
+
### Turn stages
|
|
138
63
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
64
|
+
A step that keeps state across the turn is a collaborator under `lib/samagotchi/turn_stages/`, built in
|
|
65
|
+
`Engine#initialize`, **never reading the Engine's ivars**, and called by name from the skeleton. **It takes values,
|
|
66
|
+
not callbacks**: what lives as long as the Engine goes into its constructor (the side asks, the host registry, the
|
|
67
|
+
toolkit binding, `TurnState`, the model settings, the reminder queue); what the Engine knows at the call (the
|
|
68
|
+
recorder, the sessions dir, the owner's messages, the running conversation, the host's probe) goes with each call,
|
|
69
|
+
read on the caller's thread and held only for that call. At turn time the sessions dir is the turn's
|
|
70
|
+
(`TurnContext#state_dir`, set from `session_state_dir` by `new_turn_context`); an off-turn call gets `state_dir:`.
|
|
71
|
+
`Objectives` and `VerifyAnchors` are built early; `ToolkitSuggestion` and `ContextJudge` in `build_tools_and_plugins`,
|
|
72
|
+
before the plugins load (a plugin may reach them as it loads); `Warmup` after `wire_kernel`. Their state is their own.
|
|
73
|
+
The Engine's public methods forward to them (`warm_toolkit_suggestion!`, `announce_objectives`, `propose_objective`,
|
|
74
|
+
the private `judge_late_candidates`, `show_objective_proposals`); each stage's builder is in its `engine/*.rb` part.
|
|
75
|
+
|
|
76
|
+
**The recorder.** A stage saves its System 1 asks as runs through a `SystemOne::Recorder` (`system_one/recorder.rb`,
|
|
77
|
+
`Data.define(:runs, :session_model)`; `#wrap(client, judgment, session, mode:)`, `#saving?`), which
|
|
78
|
+
`Engine#system_one_recorder` builds **fresh at each stage call**, never kept: `runs` from `#system_one_runs` (Config's
|
|
79
|
+
`system_one.runs` read then), `session_model` from `effective_model_name` then, so `outgoing` after a before_turn
|
|
80
|
+
hook's model switch names the new model and `incoming` the old one. Building one never raises: on a failure
|
|
81
|
+
`Recorder::NONE` (no runs, every client as it was, no audit ask; `system_one_recording_failed`). Not the
|
|
82
|
+
`TurnContext`'s model fields: those are frozen at `generate` with the bare guardrail name.
|
|
83
|
+
|
|
84
|
+
| Stage | Built with | Called with | Called | State |
|
|
85
|
+
|---|---|---|---|---|
|
|
86
|
+
| `TurnStages::ToolkitSuggestion` (ToolkitSuggest's ask; configuration.md (Toolkits), guardrails.md (System 1)) | side judgments, host registry, toolkit binding, tools | `start(turn, recorder:)`; `settle`, `abandon(turn, why)`, `finish` (the turn); `warm!(session)` | `start` before `refresh_profile!`; `settle` after `apply_staged_tools!` (on mode: waits up to System 1's timeout or a Stop, which wins over an answer already in; a pick ≥ threshold → `ToolkitBinding#suggest`); `abandon` at STOP CHECK A and in the ensure, then `finish`; `warm!` / `stop_warmup` (Worker start, `shutdown`) | the turn's `ToolkitSuggest::Pending` (turn thread only; another turn's is never touched; dropped at `finish`), the warm-up thread |
|
|
87
|
+
| `TurnStages::ContextJudge` ([attached-context.md](attached-context.md), [context.md](../context.md) "The judge") | side judgments, host registry, `TurnState`, `show_card:`, `current_session:` | `start(turn, recorder:)`; `late_candidates(session, messages:, recorder:, state_dir:)`; `show_cards_after_turn` | `start` after `settle`, before `apply_toolkit!` (owner turns only); `late_candidates` from `judge_late_candidates` (which returns on no session or a review session first, before reading anything else); `show_cards_after_turn` after `release_turn` | the cards an answer brought while a turn ran, under their mutex |
|
|
88
|
+
| `TurnStages::Objectives` ([objectives.md](objectives.md)) | side judgments, host registry | `incoming(turn, recorder:)`, `outgoing(turn, result, recorder:)`, `note(session, note, recorder:, state_dir:)`, `announce(session, state_dir:, running:)` → the event or nil, `propose(proposal)`, `finally(turn)` → the cards | `incoming` after the judge's `start` (owner and human prompts only); `announce` from `announce_objectives` (inside `end_turn`'s step, and between turns from any thread); `outgoing` in `complete_turn` before `:after_turn`; `note` in `add_context_note`; `propose`; `finally` from `show_objective_proposals` in the ensure | the turn's proposals under their mutex (drained at `finally`), the last view announced (touched only under the event lock); the `ObjectivesAudit` is built per call |
|
|
89
|
+
| `TurnStages::Warmup` (PromptWarmup) | `PromptWarmup`, kernel, `TurnState`, model settings, reminder queue, `system_prompt` | `after(turn, result, next_turn_waiting:)` | after `complete_turn` (reads `turn.context.settings`) | none (PromptWarmup keeps its pin and thread) |
|
|
90
|
+
| `TurnStages::VerifyAnchors` | nothing | `verified(session, anchor, state_dir:)`; `stamp_opening`, `record_verdict` | `verified` in `begin_turn` (before any `TurnContext`); `stamp_opening`, `record_verdict` inside `end_turn`'s step | none |
|
|
91
|
+
|
|
92
|
+
**Objectives' announce and cards (the Engine does the effects).** `Engine#announce_objectives` takes the event lock,
|
|
93
|
+
reads the sessions dir and `live_conversation` in it, calls the stage's `announce` and announces the event it returns
|
|
94
|
+
in the same step, so the last view is guarded by the lock that orders the events; it logs a failure
|
|
95
|
+
(`objectives_announce_failed`), never raises. `show_objective_proposals` shows the cards `finally` returns and logs a
|
|
96
|
+
failure (`objectives_proposal_failed`).
|
|
97
|
+
|
|
98
|
+
**Why three callables stay** (`spec/turn_stages/constructor_contract_spec.rb` counts the Procs a built Engine's stages
|
|
99
|
+
hold, an audit's included: 3). ContextJudge's `show_card` and `current_session` are read on the asks thread when an
|
|
100
|
+
answer comes, after the call that started the asks returned: a value given at the call would be stale. Warmup's
|
|
101
|
+
`system_prompt` is built last, only once every check passed, for its side effects (the memory index metric, the
|
|
102
|
+
prompt notes). The warm-up's other late reads stay lazy too: the model after the canceled/resumable check, the
|
|
103
|
+
host's `next_turn_waiting` probe only after the target check. Warmup keeps the kernel (ToolHost's later).
|
|
104
|
+
|
|
105
|
+
On a shared local System 1 host the judge's asks wait for the turn's end: started before `TurnStages::Warmup#after`,
|
|
106
|
+
whose warm-up waits for them, or from `run_turn`'s ensure (docs/context.md, The judge).
|
|
107
|
+
`spec/turn_stages_wiring_spec.rb` pins the stages' order among the turn's steps, their threads, the saved runs'
|
|
108
|
+
fields and the late and error paths.
|
|
142
109
|
|
|
143
110
|
### Error paths
|
|
144
111
|
|
|
145
112
|
```
|
|
146
113
|
StandardError in run_turn:
|
|
147
|
-
→
|
|
148
|
-
→ else
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
→ re-raised
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
→
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
→
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
→ a Stop with reason :signal (Worker#signalled, off the trap, retried until the loop leaves)
|
|
177
|
-
→ :turn_canceled {cancellation_reason: :signal}, saved by the Worker as after any turn;
|
|
178
|
-
Worker#run returns :signal (:stopped with the stop marker), takes no new work
|
|
179
|
-
→ not ended within Worker::SIGNAL_GRACE (a turn that ended and runs its after_turn hooks gets
|
|
180
|
-
SIGNAL_HARD_EXIT more, uncanceled): the session saved as it is, SignalException raised in the
|
|
181
|
-
loop's thread, only while the loop runs (never into start, serve or the teardown, which runs whole)
|
|
182
|
-
→ the process ends SIGNAL_HARD_EXIT (10 s) after that at the latest (exit!); a signal after the
|
|
183
|
-
loop left brings only that hard exit, and run_session_loop restores the default handlers
|
|
184
|
-
→ run_session_loop: exit 0, no discard; input queued behind the stopped turn wakes a new worker
|
|
185
|
-
|
|
186
|
-
The effective model doesn't resolve (a broken host config, HostRegistry#resolve raising):
|
|
114
|
+
→ turn.ended (raised by the after_turn/session_end hooks): logged post_turn_error, re-raised, no second ending
|
|
115
|
+
→ else end_turn(turn, "failed", save: true) (a failing save logged, turn_end_save_failed): failed_messages keeps
|
|
116
|
+
the loop's partial conversation (both loops LLM::FailedTurn.attach it), else turn.messages, with
|
|
117
|
+
TurnNote.failed at the tail; :turn_failed {error_class, message, duration_ms, + error_kind, retryable, host,
|
|
118
|
+
summary for a provider error}; re-raised
|
|
119
|
+
|
|
120
|
+
Interrupt (Ctrl-C; a one-shot only, a worker traps INT):
|
|
121
|
+
→ turn.ended: re-raised only
|
|
122
|
+
→ controller.cancel!(:ctrl_c); end_turn(turn, "canceled"): ctrl_c_messages keeps the attached conversation (the
|
|
123
|
+
finished iterations stay, as after a Stop) with TurnNote.cancelled(:ctrl_c); a call mid-batch gets an
|
|
124
|
+
interrupted result (ToolResponse.run_batch), so no call is kept without its result; a half-streamed reply is
|
|
125
|
+
not kept; :turn_canceled {cancellation_reason: :ctrl_c}; re-raised (the caller decides whether to exit)
|
|
126
|
+
|
|
127
|
+
A Stop (Engine#cancel_current_turn!, the Bridge's POST cancel) or a hook's stop_turn:
|
|
128
|
+
→ not an error: LLM::ModelResult(canceled: true, conversation:); complete_turn keeps the conversation with a
|
|
129
|
+
TurnNote.cancelled note; text streamed before the cancel stays, marked [interrupted]
|
|
130
|
+
|
|
131
|
+
A worker's TERM, HUP or INT (WorkerSignals, a spawned worker only):
|
|
132
|
+
→ a Stop with reason :signal (Worker#signalled, off the trap, retried until the loop leaves); saved as after any
|
|
133
|
+
turn; Worker#run returns :signal (:stopped with the stop marker) and takes no new work
|
|
134
|
+
→ not ended within Worker::SIGNAL_GRACE (a turn already in its after_turn hooks gets SIGNAL_HARD_EXIT more,
|
|
135
|
+
uncanceled): the session saved as it is, SignalException raised in the loop's thread, only while the loop runs
|
|
136
|
+
(never into start, serve or the teardown, which runs whole)
|
|
137
|
+
→ exit! Worker::SIGNAL_HARD_EXIT (10 s) after that at the latest; a signal after the loop left brings only that
|
|
138
|
+
hard exit
|
|
139
|
+
→ WorkerBoot.run_session_loop restores the default handlers; exit 0, no discard; input queued behind the stopped turn
|
|
140
|
+
wakes a new worker
|
|
141
|
+
|
|
142
|
+
The effective model doesn't resolve (HostRegistry#resolve raising):
|
|
187
143
|
→ the rescued readers give their fallback (SessionModelSettings#model_setting) and log effective_model_unresolved;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
→ that turn declares the full kit with the session's own added and removed tools on top (Toolkit#resolve's
|
|
199
|
-
fallback); the saved toolkit stays as it is, so a reinstalled bundle gets it back
|
|
200
|
-
→ each warning (that fallback, a config.yml kit with a problem: Toolkit.kits) logged once per process as
|
|
201
|
-
toolkit_unresolved (WARN), with a :guardrail_warning (label "toolkit") to the turn and the observers
|
|
144
|
+
a setting failing on a resolved model logs <setting>_unresolved (also served_probe_failed,
|
|
145
|
+
alias_note_failed, thinking_notice_failed); WARN once per (event, model, error class),
|
|
146
|
+
DEBUG after (EffectiveModel::Unresolved)
|
|
147
|
+
→ session_state_snapshot never raises (its model fields nil); a turn, stats_snapshot, chat_model? and
|
|
148
|
+
system_prompt raise
|
|
149
|
+
|
|
150
|
+
A session's toolkit doesn't resolve (an unknown kit, a missing bundle's kit, a bad base, a cycle; ToolkitBinding#apply!):
|
|
151
|
+
→ that turn declares the full kit plus the session's own adds and removes (Toolkit#resolve's fallback); the saved
|
|
152
|
+
toolkit stays, so a reinstalled bundle gets it back; toolkit_unresolved logged once per process, with a
|
|
153
|
+
:guardrail_warning (label "toolkit")
|
|
202
154
|
|
|
203
155
|
Stopped before the model was asked (prepare_turn returned false):
|
|
204
|
-
→ stopped_before_model
|
|
205
|
-
→ nothing kept, :turn_canceled {cancellation_reason, cancelled_by, duration_ms}
|
|
206
|
-
→ LLM::ModelResult(text: "", canceled: true, cancellation_reason: reason)
|
|
156
|
+
→ stopped_before_model → end_turn(turn, "canceled"): nothing kept; :turn_canceled; ModelResult(canceled: true)
|
|
207
157
|
```
|
|
208
158
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
159
|
+
### Degrading
|
|
160
|
+
|
|
161
|
+
Work that has a fallback when it fails (a prompt section left out, an empty list from the Bridge) runs in
|
|
162
|
+
`Degrade.call(area, event, fallback:, rescue:) { … }` (`degrade.rb`): the same rescue and fallback as a bare
|
|
163
|
+
rescue, plus one WARN in the debug log per process for each `[area, event]` (DEBUG after; nothing on stderr). A bare
|
|
164
|
+
`rescue` whose body is only a literal (`nil`, `false`, `[]`, `{}`, `""`, `0`, or an array of only those) is flagged
|
|
165
|
+
in lib by the `Samagotchi/SilentRescue` cop; a silence that is right (a client gone, a routine probe, Log's own
|
|
166
|
+
internals) carries `# degrade-ok: why`.
|
|
214
167
|
|
|
215
168
|
## The model↔tool loop
|
|
216
169
|
|
|
217
|
-
The Engine does not talk to models directly. `Engine#backend` picks the loop the effective
|
|
170
|
+
The Engine does not talk to models directly. `Engine#backend` (`engine/models.rb`) picks the loop the effective
|
|
171
|
+
model's host speaks: an api in `LLM::Adapters::BY_API` (openai, anthropic) gets `LLM::ChatLoop` (one per Engine,
|
|
172
|
+
built on first use), anything else `LLM::NativeBackend` wrapping the Engine's `KernelLoop`.
|
|
218
173
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
174
|
+
Both implement `LLM::ModelBackend#complete(messages:, max_iterations:, on_stream_event:, cancel_controller:,
|
|
175
|
+
model_name:, max_tool_output_chars:, pending_input:, reasoning_since:, context:)`. `reasoning_since:` is where the
|
|
176
|
+
run a step-limit Continue resumes started (the Engine's `resume_from`, checked in `Engine#resumed_run_start`).
|
|
177
|
+
`NativeBackend#complete` only calls `KernelLoop#run`. `ChatLoop` runs its own loop but uses the KernelLoop's tools,
|
|
178
|
+
hooks, gate and dispatch.
|
|
224
179
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
`KernelLoop#run` with the same arguments. `ChatLoop` runs its own loop but is built with the same KernelLoop,
|
|
229
|
-
whose tools, hooks, gate and dispatch it uses.
|
|
180
|
+
**Each loop works on its own copy of the conversation**, not the Engine's `turn.messages`. The Engine keeps
|
|
181
|
+
`result.conversation` after a normal end or a Stop, the copy attached with `LLM::FailedTurn.attach` after a failure
|
|
182
|
+
or a Ctrl-C, and `turn.messages` only when the turn ended before the loop.
|
|
230
183
|
|
|
231
184
|
### KernelLoop (the native, raw-prompt loop)
|
|
232
185
|
|
|
233
|
-
Runs models whose server takes a raw prompt (llama.cpp `/completion`). chi formats the prompt itself
|
|
234
|
-
parses tool calls out of the generated text.
|
|
186
|
+
Runs models whose server takes a raw prompt (llama.cpp `/completion`). chi formats the prompt itself per model
|
|
187
|
+
profile (Gemma 4, Qwen 3.6) and parses tool calls out of the generated text. `KernelLoop#run` → `start_turn`
|
|
188
|
+
(`prepare_conversation`: a copy, old model messages without their thought blocks, except a resumed run's,
|
|
189
|
+
`keep_thought_from: ReasoningHistory.run_start(messages, reasoning_since)`), then per iteration (`#iterate`):
|
|
235
190
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
│ # all of them, a steer merged mid-run included; only a user entry
|
|
244
|
-
│ # added after the run's last reply starts them past itself)
|
|
245
|
-
│ each iteration:
|
|
246
|
-
│ 1. Queued input (user lines, plugin steers) goes in at the boundary (Steer.inject!)
|
|
247
|
-
│ 2. Format the conversation as prompt text (Prompt.format_with_images); a context
|
|
248
|
-
│ guidance line goes on the tail when the window fills past a threshold
|
|
249
|
-
│ 3. Fire :before_generation, then stream one generation (:generation_started, :generation_chunk, :generation_completed)
|
|
250
|
-
│ 4. Parse tool calls (ToolCallParser for the profile):
|
|
251
|
-
│ → Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
|
|
252
|
-
│ → Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
|
|
253
|
-
│ A Qwen block left open is asked to be finished (QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2)
|
|
254
|
-
│ 5. Calls: dispatch_calls → ToolResponse.run_batch; one tool_response entry for the batch
|
|
255
|
-
│ 6. No calls: an answer, an empty answer to retry (EmptyAnswerRetry), or queued input to answer
|
|
256
|
-
│
|
|
257
|
-
└──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
`turn.conversation` is the loop's own copy, not the Engine's `turn.messages`. A failure attaches it to the error
|
|
261
|
-
(`LLM::FailedTurn.attach`), so `Engine#failed_messages` can keep it; an Interrupt (Ctrl-C) too, for
|
|
262
|
-
`Engine#ctrl_c_messages` (see Error paths). Both are `TurnEndings`', with the other pure builders of a turn's end
|
|
263
|
-
(`kept_messages`, `failed_event`, `turn_summary`, `stop_facts`); the Engine forwards to it and keeps `end_turn`,
|
|
264
|
-
`complete_turn`, `store_answer_display` and `record_last_turn`.
|
|
191
|
+
1. Queued input (user lines, plugin steers) goes in at the boundary (`Steer.inject!`).
|
|
192
|
+
2. The conversation is formatted (`Prompt.format_with_images`); a context guidance line goes on the tail past a
|
|
193
|
+
fill threshold.
|
|
194
|
+
3. `:before_generation`, then one streamed generation.
|
|
195
|
+
4. Tool calls parsed (`ToolCallParser` for the profile):
|
|
196
|
+
- Gemma 4: `<|tool_call>call:NAME{params}<tool_call|>`
|
|
197
|
+
- Qwen 3.6: `<tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>`
|
|
265
198
|
|
|
266
|
-
|
|
199
|
+
A Qwen block left open is asked to be finished (`QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT` = 2).
|
|
200
|
+
5. Calls: `dispatch_calls` → `ToolResponse.run_batch`, one `tool_response` entry for the batch.
|
|
201
|
+
6. No calls: an answer, an empty answer to retry (`EmptyAnswerRetry`), or queued input to answer.
|
|
267
202
|
|
|
268
|
-
|
|
269
|
-
providers such as OpenRouter, Claude among others, and any OpenAI-compatible chat server) and `api: anthropic` on
|
|
270
|
-
`LLM::AnthropicMessages` (Anthropic's Messages API; [anthropic.md](anthropic.md)). The Engine sets the adapter per
|
|
271
|
-
turn for the effective model's host (`HostRegistry#adapter_for`). The model gets the tools as JSON schemas and returns
|
|
272
|
-
structured tool calls.
|
|
203
|
+
Gemma 4's native contract (and thought context) is [gemma4-contract.md](gemma4-contract.md).
|
|
273
204
|
|
|
274
|
-
|
|
275
|
-
ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controller:,
|
|
276
|
-
model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)
|
|
277
|
-
│
|
|
278
|
-
│ conversation = Array(messages).map(&:dup) # a shallow copy of each message
|
|
279
|
-
│ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input,
|
|
280
|
-
│ start: reasoning_since)
|
|
281
|
-
│ .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
|
|
282
|
-
│
|
|
283
|
-
└──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
The differences from the native loop:
|
|
205
|
+
### ChatLoop (the chat APIs)
|
|
287
206
|
|
|
288
|
-
|
|
289
|
-
|
|
207
|
+
Runs hosts whose api has a chat adapter: `api: openai` on `LLM::OpenAIChat` (OpenRouter, any OpenAI-compatible
|
|
208
|
+
server) and `api: anthropic` on `LLM::AnthropicMessages` ([anthropic.md](anthropic.md)). The Engine sets the
|
|
209
|
+
adapter per turn (`HostRegistry#adapter_for`), with the session id and the loop's own client (`ChatLoop#client`, the
|
|
210
|
+
effective model's `EffectiveModel#client`: what `#context_window` probes on a local host, and the host a request's
|
|
211
|
+
re-prefill is counted against on the Engine's `ReprefillMeter`, one for both loops: prompt-caching.md, "Reading
|
|
212
|
+
the numbers"). `Engine#backend_for` writes all three under `@chat_backend_mutex`, from whichever
|
|
213
|
+
thread asks (the turn's, or a `/model` switch's through `sync_kernel_client!`); the turn reads them on its own
|
|
214
|
+
thread. The chat loop never reads the kernel's client. `ChatLoop#complete` copies the messages (each `dup`ed) and runs a
|
|
215
|
+
`ChatLoop::Run` (`start: reasoning_since`, cap `ToolOutputCut.max_chars`). Differences from the native
|
|
216
|
+
loop:
|
|
217
|
+
|
|
218
|
+
- The tools go in each request's `tools` field (`ChatLoop#tool_definitions`, the turn's `ToolSurface#schemas`).
|
|
290
219
|
On an adapter that loads tools (`#loads_tools?`: `AnthropicMessages`), the tools the session reaches but doesn't
|
|
291
|
-
declare follow
|
|
292
|
-
|
|
293
|
-
- The conversation
|
|
294
|
-
`
|
|
295
|
-
- The first system message carries `cache_split:` (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
`
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
`ChatLoop#reasoning_boundary`: the Run's `@reasoning_start`, fixed once per Run by `ReasoningHistory.run_start`
|
|
305
|
-
(the rule `KernelLoop#start_turn` uses too) from the Run's start (the conversation length at `#complete`), so a
|
|
306
|
-
continue turn (reminder, context wake, delegate report wake) replays nothing of the run before. Entries the Run
|
|
307
|
-
merges (a person's line, a plugin's steer, a Continue's text) don't move it, as natively (nothing re-sanitizes
|
|
308
|
-
within a run): the request after a steer extends the one before. A step-limit Continue's Run starts where the run
|
|
309
|
-
it resumes did: `#complete`'s `reasoning_since:` (the Engine's `resume_from`, checked against the messages' length
|
|
310
|
-
in `Engine#resumed_run_start`), past a user entry added after that run's last reply (a `!cmd`'s output). Without
|
|
311
|
-
`since:` (`#reasoning_chars` from outside a Run) the boundary is the last query (a `role: "user"` or `turn_start`
|
|
312
|
-
entry). `Run#prompt_chars` counts it (`#reasoning_chars`).
|
|
220
|
+
declare follow with `defer_loading: true` (`ToolSurface#deferred_for`; anthropic.md, "Deferred tools"); a call of
|
|
221
|
+
one is logged `tool_direct_deferred`.
|
|
222
|
+
- The conversation goes out in the OpenAI wire format (`wire_messages`); `AnthropicMessages` translates it again
|
|
223
|
+
(`LLM::AnthropicWire`). Each call's result is its own tool message, paired by `tool_call_id`.
|
|
224
|
+
- The first system message carries `cache_split:` (`SystemPrompt#stable_length`); where the adapters put cache
|
|
225
|
+
breakpoints is prompt-caching.md's and anthropic.md's.
|
|
226
|
+
- **Reasoning sent back** (`reasoning_content`) follows `TurnSettings#reasoning_history` (`ReasoningHistory.resolve`,
|
|
227
|
+
the session's own level first): `all` on every model message, `turn` past `ChatLoop#reasoning_boundary`, the Run's
|
|
228
|
+
`@reasoning_start`, fixed **once per Run** by `ReasoningHistory.run_start` (the rule the native loop uses too).
|
|
229
|
+
So a continue turn (reminder, context wake, report wake) replays nothing of the run before, and entries the Run
|
|
230
|
+
merges (a steer, a person's line) don't move it: the request after a steer extends the one before. A step-limit
|
|
231
|
+
Continue's Run starts where the run it resumes did (`reasoning_since:`). Outside a Run (`#reasoning_chars`) the
|
|
232
|
+
boundary is the last query.
|
|
313
233
|
- On an adapter that sends its blocks back (`#replays_content?`: `AnthropicMessages`), a model turn's saved blocks
|
|
314
|
-
(`anthropic_content`,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
`#reasoning_history` floors `drop` at `turn` there, since Messages needs the running run's thinking blocks.
|
|
234
|
+
(`anthropic_content`, saved by `Run#with_thinking`) go back verbatim with their signatures (`#replay_blocks`), not
|
|
235
|
+
for an interrupted entry or one with an unpaired call, and without thinking blocks before the reasoning boundary.
|
|
236
|
+
`#reasoning_history` floors `drop` at `turn` there.
|
|
318
237
|
- A 400 about reasoning (`BadRequest#reasoning_refused?`) for a request that sent reasoning back asks the step again
|
|
319
|
-
without it (not on a replaying adapter
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
block's signature (`BadRequest#thinking_signature?`) or a refused binding control
|
|
325
|
-
(`#thinking_binding_unsupported?`) is not one: `ProviderErrors` leaves `reasoning_refused?` false for them, so one
|
|
326
|
-
bad block never turns reasoning off for the model.
|
|
238
|
+
without it (not on a replaying adapter); when that goes through, `#reasoning_history_refused!` marks the model
|
|
239
|
+
for the worker's life (`LLM::RefusalMemo`) and `:reasoning_history_refused` goes out; refused again, the
|
|
240
|
+
thinking-fields fallback (`#thinking_refused!`) runs. Up to three requests per step. A bad thinking signature
|
|
241
|
+
(`#thinking_signature?`) or a refused binding control (`#thinking_binding_unsupported?`) is **not** a reasoning
|
|
242
|
+
refusal (`ProviderErrors`), so one bad block never turns reasoning off for the model.
|
|
327
243
|
|
|
328
244
|
### Shared: ToolResponse (per batch) and ToolRunner (per call)
|
|
329
245
|
|
|
330
|
-
Both loops run a batch of calls the same way:
|
|
331
|
-
|
|
332
246
|
```
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
→
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
doesn't hold) skips the gate: no hooks, no card; KernelLoop#dispatch answers it (the call_tool route for a
|
|
348
|
-
reachable tool, the not-loaded error for a removed one). Under the surface's loads_tools a reachable
|
|
349
|
-
built-in or plugin tool called by its own name is no such call: it runs through the gate, and KernelLoop#dispatch
|
|
350
|
-
takes it (ToolSurface#entry_for)
|
|
351
|
-
→ Guardrails::Gate#evaluate(call, context:) (the before_tool_call hooks, then the core checks)
|
|
352
|
-
→ :tool_call_started
|
|
353
|
-
→ an ask verdict: Gate#settle_ask(verdict, context:) (asks the user, records waited_ms)
|
|
354
|
-
→ KernelLoop#dispatch_tool_call(call, context), or the denial text
|
|
355
|
-
→ :tool_call_completed, then the :after_tool_call hook
|
|
356
|
-
→ returns a ToolRun value object with output, capped_output, truncated, activity, images, shown_params,
|
|
357
|
-
shown_label, refs, diff and output_file; fields a call doesn't produce are nil
|
|
358
|
-
→ :tool_dispatch_completed {iteration, call_count}
|
|
359
|
-
→ native: ToolResponse.joined(runs, ids:), one entry with the capped outputs
|
|
360
|
-
chat: ToolResponse.single(run, tool_call_id:, ids:) as each call finishes (the block), with the
|
|
361
|
-
capped output
|
|
362
|
-
ids: ToolIds.next_ids, chi's own ids for the outputs ("t41"), saved on the entry as tool_ids
|
|
363
|
-
(one per call of the batch for native, one for chat); LLM context edits name outputs by them
|
|
247
|
+
KernelLoop#dispatch_calls / ChatLoop::Run#dispatch
|
|
248
|
+
→ ToolResponse.run_batch(runner, calls, context:, iteration:, emit:, on_stream_event:, cap:)
|
|
249
|
+
→ :tool_dispatch_started
|
|
250
|
+
→ per call: ToolRunner#run(call, context:, …)
|
|
251
|
+
wrap (under loads_tools: a direct call of a source tool's acts_as → its call_tool call; Registry::Index#wrap)
|
|
252
|
+
unwrap (Registry::Entry#unwrap: call_tool of chi/<name> → that tool's call, via: "call_tool")
|
|
253
|
+
a tool the toolkit leaves out (ToolSurface#left_out) skips the gate: KernelLoop#dispatch answers it
|
|
254
|
+
Guardrails::Gate#evaluate → :tool_call_started → Gate#settle_ask (an ask) → KernelLoop#dispatch_tool_call
|
|
255
|
+
or the denial text → :tool_call_completed → :after_tool_call hook
|
|
256
|
+
→ a ToolRun value (output, capped_output, truncated, activity, images, shown_params, shown_label, refs,
|
|
257
|
+
diff, output_file; nil when a call doesn't produce one)
|
|
258
|
+
→ :tool_dispatch_completed
|
|
259
|
+
→ native: ToolResponse.joined(runs, ids:), one entry; chat: ToolResponse.single(run, tool_call_id:, ids:) per call
|
|
260
|
+
ids: ToolIds.next_ids ("t41"), saved as tool_ids; LLM context edits name outputs by them
|
|
364
261
|
```
|
|
365
262
|
|
|
366
|
-
**ToolRunner** (`ToolRunner#run`): what it reads of the turn is the `context:` it is given (a `TurnContext`): the
|
|
367
|
-
session's folders (`cwd`, `session_dir`), the settings' vision, LLM context layers and `loads_tools`, the
|
|
368
|
-
participant's author and mode; the kernel gives it only what lives as long as the Engine (tools, hooks, the gate).
|
|
369
|
-
- Unwraps a call_tool of a built-in first (`Registry::Entry#unwrap`), so `original`, the params and the label
|
|
370
|
-
are the built-in's and no "ran as:" line follows.
|
|
371
|
-
- Runs the gate first, so `:tool_call_started` shows the call that will run (a before_tool_call hook may
|
|
372
|
-
replace it; known-names corrects a misspelled name this way, and the model gets one line saying what ran).
|
|
373
|
-
- Emits `:tool_call_started` {iteration, call_count, call_index, tool, call, params, label (plugin tools),
|
|
374
|
-
title, called_as, via, view, ref, refs}. The row fields (title, called_as, via, view, ref) come from `ToolRowFields.for`, which the
|
|
375
|
-
reload (`MessageParts#tool_part`, after `BuiltinCalls.unwrap_saved`) calls too. `ToolRowFields::ACTIVITY_KEYS` (title, called_as, via) travel
|
|
376
|
-
inside `activity` on the completed event; `EVENT_KEYS` (view, ref, refs) go top-level on both events.
|
|
377
|
-
`Bridge::TurnAccumulator` (snapshot and replay) and the web (`activity.js` `ACTIVITY_FIELDS` /
|
|
378
|
-
`EVENT_FIELDS`) copy them by these lists, so a new row field is added there. `ref` is `FileRef.for` (a
|
|
379
|
-
read/write/edit's file against the cwd, `{kind: "file", path, line?, end_line?}`); the web opens it in the editor
|
|
380
|
-
that `Web::Editor` (web.editor) names, for a local viewer only (`App#viewer_capabilities`).
|
|
381
|
-
`refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `ToolSpec#per_call`), run
|
|
382
|
-
once per call by `RowRefs.for` (never raises: a raise or a non-list gives none, `tool_refs_failed`; each entry a
|
|
383
|
-
`Ref`, a bad one dropped, `tool_refs_rejected`; a relative file resolved against the session's folder
|
|
384
|
-
(`TurnContext#cwd`, else the process's), one outside it (its spelling or its real path) dropped, a kept one
|
|
385
|
-
stored under the real path; at most `RowRefs::MAX_REFS` and `MAX_BYTES`). `ToolRowFields.for` can't build it (no plugins on a reload), so the run carries it
|
|
386
|
-
as `refs:`, saved with the result as `tool_refs` (`ToolResponse::SAVED_KEYS`, never sent), and
|
|
387
|
-
`MessageParts#tool_part` reads it back through `RowRefs.for_display` (checked again). The page binds them to the
|
|
388
|
-
row's title (`refs.js bindRowRefs`, from `turn_block.js` live and on a reload): the first ref on it, the rest in its
|
|
389
|
-
menu.
|
|
390
|
-
- Settles an ask verdict after `:tool_call_started`, so a UI shows the tool line and then the approval.
|
|
391
|
-
- Dispatches through `KernelLoop#dispatch_tool_call`, or writes the denial text. `KernelLoop#dispatch` runs the call
|
|
392
|
-
on the entry `ToolSurface#entry_for` gives; one without an entry gets `ToolSurface#unknown_tool_error` ("Error: no
|
|
393
|
-
such tool ...", never repeating the wrong name).
|
|
394
|
-
- For edit and write: diffs the file before and after, and refreshes a memory's index line
|
|
395
|
-
(`MemoryBundle::IndexSync`).
|
|
396
|
-
- Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
|
|
397
|
-
- Scrubs bytes that aren't UTF-8, and caps the output at `max_tool_output_chars` (a `memory_read`'s is `memory.read_max_chars`, 0: none, with an overflow guard at half a known window) by the tool's `ToolOutputCut`
|
|
398
|
-
policy: `read` and most tools are cut to their head and end with `[cut: N of M chars; read it in parts]` (N
|
|
399
|
-
kept of M, the whole within the cap unless the cap is shorter than that line); an `execute` result keeps a head
|
|
400
|
-
and a long tail with a gap line between them, its whole output saved in `sessions/<id>/tool_output/`
|
|
401
|
-
(`ToolOutputFiles`, in the turn's `TurnContext#session_dir`). See
|
|
402
|
-
[Tool output guardrails](tool-output-limits.md#the-runners-cut-max_tool_output_chars). The model (both loops), the
|
|
403
|
-
event and the after_tool_call hook get that text.
|
|
404
|
-
- Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, output_file,
|
|
405
|
-
activity, images, diff, waited_ms, view, ref, refs}, then fires `:after_tool_call` {iteration, tool, output,
|
|
406
|
-
status, call, elapsed_ms, result}.
|
|
407
|
-
`output_file` (the saved whole output) is also on the run hash; the session doesn't keep it (the cut text names it).
|
|
408
|
-
- The activity's status (`ToolActivity#tool_activity_status`; a call the gate denied is `blocked`, set by
|
|
409
|
-
`ToolRunner#denied`): `stopped` for a call the turn's Stop (or a signal to chi, or the session's stop) left
|
|
410
|
-
unrun (`Tools::Execute::NOT_RUN_ON_STOP`, `NOT_RUN`) or an execute it killed (`STOPPED_BY_USER`, `STOPPED_BY`),
|
|
411
|
-
and for a `task_wait` whose header (before `output_tail:`) says `wait_result: canceled` or `status: stopped`;
|
|
412
|
-
`ok` for an execute handed over as a task (`#handed_over?`: its output tail may hold any `exit: N`); `error` for
|
|
413
|
-
any other `Error:` result, for a `task_wait` whose header says `status: failed`, and for an execute that exited
|
|
414
|
-
non-zero (a numberless `exit:` line, killed by a signal, counts), except a grep-family search that found nothing
|
|
415
|
-
(`ToolActivity.no_match?`:
|
|
416
|
-
exit 1, no stderr block, the last step is grep/egrep/fgrep/rg/ag/pgrep, no `&&` after a step that can fail
|
|
417
|
-
silently or sends output to /dev/null, no background `&`, no earlier exit/return or `set -e`; when unsure,
|
|
418
|
-
not a no-match). That one stays `ok` with `no_match: true` on the activity, which SessionMetrics keeps on the tool
|
|
419
|
-
record and the Bridge's TurnAccumulator on the part, for the web row's "no match" word. The model's text is
|
|
420
|
-
unchanged.
|
|
421
|
-
|
|
422
263
|
A denied call gets denial text in place of its output; the rest of the batch still runs. After a hook's
|
|
423
|
-
`stop_turn`, the gate denies every remaining call of the batch.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
`
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
`
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
(`
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
264
|
+
`stop_turn`, the gate denies every remaining call of the batch. Under the surface's `loads_tools` a reachable
|
|
265
|
+
built-in or plugin tool called by its own name is not a left-out call: it runs through the gate
|
|
266
|
+
(`ToolSurface#entry_for`).
|
|
267
|
+
|
|
268
|
+
**ToolRunner** (`tool_runner.rb`) reads of the turn only the `context:` it is given (folders, the settings' vision,
|
|
269
|
+
LLM context layers and `loads_tools`, the participant's author and mode); the kernel gives it only what lives as
|
|
270
|
+
long as the Engine (tools, hooks, the gate). The rules an edit gets wrong:
|
|
271
|
+
|
|
272
|
+
- The gate runs **before** `:tool_call_started`, so the event shows the call that will run (a before_tool_call
|
|
273
|
+
hook may replace it; known-names corrects a misspelled name this way). An ask is settled **after** it, so a UI
|
|
274
|
+
shows the tool line, then the approval. An unwrapped call_tool shows the built-in's params and label.
|
|
275
|
+
- **Row fields.** `ToolRowFields.for` builds the row fields (title, called_as, via, view, ref) live and on a reload
|
|
276
|
+
(`MessageParts#tool_part`, after `BuiltinCalls.unwrap_saved`). `ToolRowFields::ACTIVITY_KEYS` travel inside
|
|
277
|
+
`activity`; `EVENT_KEYS` go top-level on both events. `Bridge::TurnAccumulator` and the web (`activity.js`
|
|
278
|
+
`ACTIVITY_FIELDS` / `EVENT_FIELDS`) copy them by these lists, **so a new row field is added there too**. `ref` is
|
|
279
|
+
`FileRef.for` (opened in `Web::Editor`'s editor, for a local viewer only).
|
|
280
|
+
- **Row refs.** A plugin tool's `refs:` lambda (`ToolSpec#per_call`) runs once per call through `RowRefs.for`, which
|
|
281
|
+
never raises (`tool_refs_failed`, `tool_refs_rejected`), resolves relative files against `TurnContext#cwd` (else
|
|
282
|
+
the process's), drops a ref whose spelling **or** real path is outside it, stores a kept one under its real path
|
|
283
|
+
(`File.realpath`), and caps at `RowRefs::MAX_REFS` / `MAX_BYTES`. A reload has no plugins, so the run saves them as
|
|
284
|
+
`tool_refs` (`ToolResponse::SAVED_KEYS`, never sent) and `MessageParts#tool_part` re-checks them
|
|
285
|
+
(`RowRefs.for_display`).
|
|
286
|
+
- An unknown tool gets `ToolSurface#unknown_tool_error` ("Error: no such tool ...", never repeating the wrong name).
|
|
287
|
+
- Edit and write: the file is diffed, and a memory's index line refreshed (`MemoryBundle::IndexSync`). At most
|
|
288
|
+
`MAX_IMAGES_PER_RESULT` images per result. Non-UTF-8 bytes are scrubbed.
|
|
289
|
+
- The output cap (`max_tool_output_chars`, a `memory_read`'s `memory.read_max_chars`) cuts by the tool's
|
|
290
|
+
`ToolOutputCut` policy; an execute's whole output is saved by `ToolOutputFiles` in `TurnContext#session_dir`. See
|
|
291
|
+
[tool-output-limits.md](tool-output-limits.md#the-runners-cut-max_tool_output_chars). The model, the event and the
|
|
292
|
+
after_tool_call hook all get the cut text; `output_file` is on the run and the event, never in the session.
|
|
293
|
+
- **The activity's status** (`ToolActivity#tool_activity_status`): `blocked` for a gate denial
|
|
294
|
+
(`ToolRunner#denied`); `stopped` for a call a Stop or signal left unrun (`Tools::Execute::NOT_RUN_ON_STOP`,
|
|
295
|
+
`NOT_RUN`), an execute it killed (`STOPPED_BY_USER`, `STOPPED_BY`) or a `task_wait` whose header says canceled or
|
|
296
|
+
stopped; `ok` for an execute handed over as a task (`#handed_over?`); `error` for any other `Error:` result, a
|
|
297
|
+
failed `task_wait`, and an execute that exited non-zero, **except** a grep-family search that found nothing
|
|
298
|
+
(`ToolActivity.no_match?`, conservative: when unsure, not a no-match), which stays `ok` with `no_match: true`
|
|
299
|
+
(kept by SessionMetrics and the TurnAccumulator for the web's "no match" word). The model's text is unchanged.
|
|
300
|
+
|
|
301
|
+
The two events' payloads are in "Events" below.
|
|
302
|
+
|
|
303
|
+
### ToolSurface
|
|
304
|
+
|
|
305
|
+
`Tools::ToolSurface` (`tools/tool_surface.rb`): the turn's tools as **one frozen value**, built once in
|
|
306
|
+
`Engine#generate` (`ToolSurface.for(registry, layers:, loads_tools:)`) into `TurnContext#surface`. The Registry is its
|
|
307
|
+
only input (`entries`, `reachable_names`, `left_out_names`, `removed_names`, `stranded_names`). It holds `declared`
|
|
308
|
+
(prompt order), `index_only` (reached by `call_tool`), `left_out` (`{name => :indexed | :user_only}`), `removed`,
|
|
309
|
+
`stranded`, `registered`, `toolkit`, `reaching_kit`, `index`, `layers`, `loads_tools`, and answers `entry_for(call)` (a `via:` call only from
|
|
310
|
+
`index_only`; a direct one from `declared`, or `index_only` under `loads_tools`), `unknown_tool_error`, `schemas` and
|
|
311
|
+
`deferred_for(conversation)` (the index's source tools are asked per request, so a late MCP server joins mid-turn).
|
|
312
|
+
|
|
313
|
+
- **Who reads it**: `KernelLoop#dispatch`, `ChatLoop#tool_definitions` / `#deferred_tools` (whether to append
|
|
314
|
+
deferred ones is still the adapter's `#loads_tools?` per request: a refusal turns it off mid-turn), the runner's
|
|
315
|
+
wrap / unwrap / left-out / group-refusal checks, `find_tools` and `call_tool` (`KernelLoop::ToolContext#surface`),
|
|
316
|
+
and the gate's and the row's `call_targets` / `call_preview` (an entry's `preview` / `targets` take
|
|
317
|
+
`(call, surface)`; `Registry#register` wraps a one-argument one).
|
|
318
|
+
- **A change mid-turn** (`/toolkit`, a staged `chi.replace_tools`) shapes the next turn's surface only.
|
|
319
|
+
- **Fail closed**: a context without a surface raises `Tools::MissingSurface` (`ToolSurface.of`); the `targets:` /
|
|
320
|
+
`preview:` rescues and the runner's unwrap re-raise it, so the call never reaches the gate. A bare kernel's or
|
|
321
|
+
loop's own context builds one (`ToolSurface.bare`: `KernelLoop#own_context`, `ChatLoop#turn_context`).
|
|
322
|
+
- **Outside a turn's context**: on the turn thread, built from the Registry (`ToolSurface.for`: the prompt, cached
|
|
323
|
+
per build before `generate` knows `loads_tools`, `SystemPrompt::Built#surface`; `#warn_stranded`; the
|
|
324
|
+
suggestion's lists; the index's `#declare` / `#refresh!`). Off the turn thread, from `ToolkitBinding#published`
|
|
325
|
+
only (`#explained` for `/toolkit` and the snapshot, `ctx.load_tools`).
|
|
449
326
|
|
|
450
327
|
## State management
|
|
451
328
|
|
|
452
329
|
### TurnState (cross-thread)
|
|
453
330
|
|
|
454
|
-
One object, guarded by a `Monitor`, that the turn thread writes (`begin_turn`, `release_turn`)
|
|
455
|
-
read: the idle scheduler, the Bridge's request threads, plugin threads, the UIs.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
- `
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
turn's author.
|
|
474
|
-
- `replace_context(context)`: the turn thread's new context for its running turn (`generate`'s settings), one
|
|
475
|
-
assignment of a frozen value; refused from another thread or for another turn's id.
|
|
476
|
-
- `ended!`: the turn's end is decided (`end_turn`, next to `turn.ended`): its after_turn / session_end hooks run
|
|
477
|
-
after it and before `finish!`.
|
|
478
|
-
- `own_controller(handler:)`: the controller for work done for the turn itself, which a plugin's `ctx.http` call
|
|
479
|
-
without `cancel:` stops with (`Plugin::Host#cancel_controller`, `Engine#plugin_cancel_controller`): the turn's, on
|
|
480
|
-
its thread or inside a plugin handler, until `ended!`; nil elsewhere (an anytime command, a service, a plugin's
|
|
481
|
-
own thread) and outside a turn. An init task's own controller wins inside one.
|
|
482
|
-
- `finish!`: not running, context, controller, thread and sink cleared, the context kept as `last_context` (with the
|
|
483
|
-
last settings when the turn never reached `generate`); returns the steers never taken (logged as dropped).
|
|
484
|
-
- `steer(text, source:)`: queues a steer for the running turn (false with no turn).
|
|
485
|
-
- `steer_next_turn(text, source:)`: queues a steer for the next turn that begins.
|
|
486
|
-
|
|
487
|
-
The lock is a leaf: nothing is called out while it is held.
|
|
331
|
+
One object (`turn_state.rb`), guarded by a `Monitor`, that the turn thread writes (`begin_turn`, `release_turn`)
|
|
332
|
+
and other threads read: the idle scheduler, the Bridge's request threads, plugin threads, the UIs. It holds
|
|
333
|
+
running, `context` (nil between turns), `last_context`, `controller`, the turn's thread and `ended`, the sink,
|
|
334
|
+
steers and carried steers, and the activity clock.
|
|
335
|
+
|
|
336
|
+
- `begin!(context:, controller:, sink:, input_waiting:)`: all of it in one step, carried steers become steers. The
|
|
337
|
+
context is built whole before (mode and git included), **so no reader sees a running turn with the last turn's
|
|
338
|
+
author**.
|
|
339
|
+
- `replace_context(context)`: the turn thread's new frozen context (`generate`'s settings); refused from another
|
|
340
|
+
thread or for another turn's id.
|
|
341
|
+
- `ended!`: the end is decided (`end_turn`); the after_turn / session_end hooks run after it, before `finish!`.
|
|
342
|
+
- `own_controller(handler:)`: the controller a plugin's `ctx.http` without `cancel:` stops with
|
|
343
|
+
(`Plugin::Host#cancel_controller`): the turn's, on its thread or inside a plugin handler, until `ended!`; nil
|
|
344
|
+
elsewhere (an anytime command, a service, a plugin's own thread). An init task's own controller wins inside one.
|
|
345
|
+
- `finish!`: everything cleared, the context kept as `last_context` (with the last settings when it never reached
|
|
346
|
+
`generate`); returns the steers never taken.
|
|
347
|
+
- `steer(text, source:)` (false with no turn), `steer_next_turn(text, source:)`.
|
|
348
|
+
|
|
349
|
+
**The lock is a leaf: nothing is called out while it is held.**
|
|
488
350
|
|
|
489
351
|
**Reading the turn from another thread.** A thread off the turn (a Bridge request, an anytime command, a plugin's
|
|
490
|
-
thread, a hook fired by one) reads the running turn only through `Engine#turn_context`
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
`
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
352
|
+
thread, a hook fired by one) reads the running turn **only** through `Engine#turn_context` and
|
|
353
|
+
`Engine#last_turn_context`, one read each, never through the kernel, `GuardrailWiring` or an Engine ivar. The
|
|
354
|
+
readers that do: `Engine#turn_author`, `#side_author`, `EventFanout#working_set_llm_context` and `#live_conversation`
|
|
355
|
+
(nil unless the loop's `LLMContextForget::Turn#turn_id` is the running turn's). The turn's own code takes the
|
|
356
|
+
context as an argument. The turn stages' reads off the turn: ContextJudge's asks thread reads `current_session`
|
|
357
|
+
(`@session`) and shows a card through `show_card` (`Engine#show_card`, whose route `EventRoutes` reads on that thread),
|
|
358
|
+
and asks `TurnState#running?`; `announce_objectives` from a Bridge, command or Check this thread reads
|
|
359
|
+
`live_conversation` and `session_state_dir` under the event lock. Cancel stays `TurnState#controller` (a Stop never reads `context.controller`).
|
|
360
|
+
`spec/turn_context_readers_spec.rb` reads each the moment a turn is published. The tool Registry likewise: off the
|
|
361
|
+
turn thread it is `ToolkitBinding#published`, a frozen `Registry#snapshot` swapped at the Engine's build, after
|
|
362
|
+
`apply_staged_tools!` and after `apply_toolkit!`.
|
|
501
363
|
|
|
502
364
|
### EffectiveModel (a snapshot per read)
|
|
503
365
|
|
|
504
|
-
`EffectiveModel.of(registry, choice)` resolves the model once (`HostRegistry#resolve
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
- The Engine's readers take one (`Engine#effective_model`); those
|
|
511
|
-
|
|
512
|
-
them); `system_prompt`, `steer_cut_after`
|
|
513
|
-
`stats_snapshot` and `session_state_snapshot`
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
`guardrail_model_name`, `sync_model_key!`, `served_model(probe: false)` (`HostRegistry#model_ref`/`#bare_name`).
|
|
366
|
+
`EffectiveModel.of(registry, choice)` (`effective_model.rb`) resolves the model once (`HostRegistry#resolve`) and
|
|
367
|
+
reads each setting from it (sampling, thinking, reasoning history, steer cut, LLM context, window, price, served
|
|
368
|
+
model, vision, profile). **It is built per read and never kept**: config.yml is read live, a model listing can move
|
|
369
|
+
a bare id to another host, and another process's saved model lists change what a resolve gives, all without a
|
|
370
|
+
`/model`. Frozen, so any thread may hold one.
|
|
371
|
+
|
|
372
|
+
- The Engine's readers take one (`Engine#effective_model`); those with a fallback go through
|
|
373
|
+
`SessionModelSettings#model_setting` (`session_model_settings.rb`). A turn takes one in `turn_settings` (before
|
|
374
|
+
the hooks) and one in `generate` (after them); `system_prompt`, `steer_cut_after` and the warm-up take their own;
|
|
375
|
+
`stats_snapshot` and `session_state_snapshot` (`EngineSnapshots`, `engine_snapshots.rb`) one each.
|
|
376
|
+
- Never resolved (they hold on a broken resolve): `effective_model_ref`, `bare_model_name`, `guardrail_model_name`,
|
|
377
|
+
`sync_model_key!`, `served_model(probe: false)`.
|
|
517
378
|
- Readers on other threads: the Bridge (`/state`, `/stats`, the SSE reset, a steer's cut wait), the worker's
|
|
518
|
-
command thread, the recap thread
|
|
519
|
-
- `ModelChoice` (`ModelBinding#choice`) holds the name and the typed name as one value
|
|
520
|
-
|
|
521
|
-
- `
|
|
522
|
-
|
|
523
|
-
`
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
379
|
+
command thread, the recap thread, plugin threads.
|
|
380
|
+
- `ModelChoice` (`ModelBinding#choice`) holds the name and the typed name as **one value**, so a reader never pairs
|
|
381
|
+
one model's name with another's typed name.
|
|
382
|
+
- Every reader passes its `ModelLookup` (`model_lookup.rb`) to the setting module: one input for every layer (the
|
|
383
|
+
target's host entry, the lookup names, the `models:` map, and the env the CLI/env/file layers are read with, the
|
|
384
|
+
registry's own: `HostRegistry#env`). Each module (`Thinking`, `ReasoningHistory`, `SteerCutAfter`,
|
|
385
|
+
`SamplingSettings`, `VisionSupport`, `ContextWindow.setting`, `LLMContextStrategy`) keeps its own ordered
|
|
386
|
+
`resolve` over it and never names `ModelLookup` or `ModelSettings` (the class map's rule: the modules' parsers are
|
|
387
|
+
what `ModelSettings` calls). `ModelProfile.resolve` takes its `models` map. `.of(registry, choice, models:)` takes
|
|
388
|
+
one map for many models (the start page's picker).
|
|
389
|
+
- `EffectiveModel.of_session` builds a saved session's (no worker): `SessionSetting#saved_summary`, and
|
|
390
|
+
`EffectiveModel.session_llm_context` / `.session_budget` (a session page, a session list's ctx %).
|
|
391
|
+
- The registry: an Engine (and the TerminalUI) takes `HostRegistry.current` once at its start, so a running worker
|
|
392
|
+
keeps the hosts it started with; commands and chi web read `.current` (one per process, built again when
|
|
393
|
+
`hosts:` changes; it refuses a client override, so a spec's `client:` goes on a registry of its own).
|
|
394
|
+
|
|
395
|
+
`SessionSetting` (`session_setting.rb`) describes the four per-session settings (llm_context, thinking,
|
|
396
|
+
reasoning_history, toolkit): own value, commands, chips, a saved session's chip, the web's fallback. The session
|
|
397
|
+
state, `/stats` (no toolkit), `:command_ran` and the web's session JSON list them from it.
|
|
527
398
|
|
|
528
399
|
### ModelBinding (the choice and its profile)
|
|
529
400
|
|
|
530
|
-
`ModelBinding` (`model_binding.rb`) holds the
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
`
|
|
534
|
-
and `model_key` to it.
|
|
401
|
+
`ModelBinding` (`model_binding.rb`) holds the model choice, the profile given to `Engine.new` (until a switch), the
|
|
402
|
+
prompt profile resolved for the choice (a `[choice, resolution]` pair) and the memory overlay keys, under one mutex.
|
|
403
|
+
The Engine forwards `profile_resolution`, `refresh_profile!`, `store_profile`, `apply_profile`, `resolve_profile`,
|
|
404
|
+
`sync_model_key!` and `model_key` to it.
|
|
535
405
|
|
|
536
|
-
- `#switch!`
|
|
537
|
-
|
|
538
|
-
- `#store_profile` keeps
|
|
539
|
-
|
|
540
|
-
- `#apply_profile` runs under the mutex
|
|
541
|
-
|
|
406
|
+
- `#switch!` is one assignment under the mutex: a reader sees the old pair or the new one. It drops the given
|
|
407
|
+
profile and the kept resolution.
|
|
408
|
+
- `#store_profile` keeps a resolution only while the choice is still the one it was resolved for, **by identity**
|
|
409
|
+
(`equal?`): a `/stats` probing on a Bridge thread while `/model` runs keeps nothing.
|
|
410
|
+
- `#apply_profile` runs under the mutex (prompts for another profile dropped, then `KernelLoop#use_profile!`).
|
|
411
|
+
Nothing else takes the mutex.
|
|
542
412
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
host can't do, off the model didn't honour, a host refusing the thinking fields or the reasoning sent back. It
|
|
547
|
-
keeps the turn's `[level, ModelTarget]` and whether the generation thought; `turn_settings` and the stream handler
|
|
548
|
-
call it. Turn thread only.
|
|
413
|
+
`ThinkingNotices` (`thinking_notices.rb`) says once per session and host what a turn's thinking level leads to (an
|
|
414
|
+
effort the host can't do, an off not honoured, a host refusing the thinking fields or the reasoning sent back).
|
|
415
|
+
Turn thread only.
|
|
549
416
|
|
|
550
417
|
### Turn and TurnContext
|
|
551
418
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
`LLM::TurnSettings` the requests send), `model_key`/`fallback_model_key` (memory overlays) and `model_name` (the
|
|
570
|
-
gate's rules' `models:`) are nil until `generate` sets them, after the `before_turn` hook that may switch the
|
|
571
|
-
model (`turn.prepared_settings`, computed before the hooks, give vision, sampling and thinking: a hook's switch
|
|
572
|
-
gets the old model's, as before). `surface` (the turn's `Tools::ToolSurface`, see "Shared: ToolResponse (per
|
|
573
|
-
batch) and ToolRunner (per call)") is nil until `generate` builds it with the settings.
|
|
574
|
-
- The live bits are objects it holds, not copies: `session` (its working-set pins are read at call time),
|
|
575
|
-
`controller` (cancel state), `git` (`Guardrails::GitInfo`, the per-turn git cache).
|
|
576
|
-
- Down the tool path it is an argument: `ModelBackend#complete(context:)` → `KernelLoop#run(context:)` (the run's
|
|
577
|
-
own copy takes the run's model name; `KernelLoop#context` / `#turn_settings` read it on the turn thread, the last
|
|
578
|
-
run's after it) or `ChatLoop#complete(context:)` (`ChatLoop#turn_context`, its Run's) → `ToolResponse.run_batch` →
|
|
579
|
-
`ToolRunner#run` → `Guardrails::Gate#evaluate` / `#settle_ask` and `KernelLoop#dispatch_tool_call(call, context)` →
|
|
580
|
-
the handler's `KernelLoop::ToolContext.new(kernel, context)` (`participant_mode`, `model_key`, `muted_memory_read`,
|
|
581
|
-
`forget_outputs`). The generation guard gets it too (`KernelLoop::Services#generation_guard`, `guard_participant_spend`).
|
|
582
|
-
- After a chat-loop turn the kernel's own `context` / `turn_settings` still hold the last native run's (a chat run
|
|
583
|
-
never sets them), and `ChatLoop#turn_context` keeps its last run's: both are the turn thread's working copies, so
|
|
584
|
-
a reader outside the turn uses `Engine#turn_context` / `#last_turn_context` instead.
|
|
585
|
-
- A spec runs a loop, a runner or a gate outside an Engine turn with `run_context(settings: …)` (spec/support/test_kernel.rb).
|
|
419
|
+
The `TurnContext` (`turn_context.rb`, a frozen `Data.define`; `TurnContext.none` for a bare kernel, a spec, the
|
|
420
|
+
bundle test kit) holds what the tool path reads of a turn; `Engine::Turn` keeps only lifecycle state. The
|
|
421
|
+
KernelLoop has its own `Turn` struct, a run's.
|
|
422
|
+
|
|
423
|
+
- **Nil until `generate`**: `settings` (`LLM::TurnSettings`), `model_key` / `fallback_model_key` (memory overlays),
|
|
424
|
+
`model_name` (the gate's rules' `models:`) and `surface`. They are set after the `before_turn` hook that may switch
|
|
425
|
+
the model. `turn.prepared_settings` (before the hooks) give vision, sampling and thinking, so a hook's switch gets
|
|
426
|
+
the old model's for those.
|
|
427
|
+
- The live bits are objects it holds, not copies: `session` (working-set pins read at call time), `controller`,
|
|
428
|
+
`git` (`Guardrails::GitInfo`, the per-turn git cache).
|
|
429
|
+
- **Down the tool path it is an argument**: `ModelBackend#complete(context:)` → `KernelLoop#run` / `ChatLoop::Run` →
|
|
430
|
+
`ToolResponse.run_batch` → `ToolRunner#run` → `Gate#evaluate` / `#settle_ask` and `KernelLoop#dispatch_tool_call`
|
|
431
|
+
→ `KernelLoop::ToolContext.new(kernel, context)`. The generation guard gets it too
|
|
432
|
+
(`KernelLoop::Services#generation_guard`, `guard_participant_spend`).
|
|
433
|
+
- The kernel's own `context` / `turn_settings` and `ChatLoop#turn_context` are the turn thread's working copies
|
|
434
|
+
(after a chat turn the kernel's still hold the last native run's): **a reader outside the turn uses
|
|
435
|
+
`Engine#turn_context` / `#last_turn_context`**.
|
|
586
436
|
- What lasts the Engine's life is not in it: `KernelLoop::Services` (hooks, tools, reminder store, mutes, the
|
|
587
|
-
question handler, peers, the gate, warm-up, the generation guard), one frozen value
|
|
588
|
-
`KernelLoop.new(services:)` or `#attach
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
- `
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
boundary) copies it, with the lines that boundary merges, into `Session#turn_checkpoint` (`TurnCheckpoint`):
|
|
604
|
-
every save while the turn runs writes that copy with an `interrupted_turn` mark instead of the session's
|
|
605
|
-
messages, written at the first boundary and then at most every 10 s, so a killed worker loses at most that much
|
|
606
|
-
of its turn. `session.messages` itself is untouched until the turn ends. When the checkpoint writes, the worker
|
|
607
|
-
also calls `Engine#checkpoint_metrics` (`SessionMetrics#persist(running_turn: true)`): `analytics.json` gets the
|
|
608
|
-
running turn's record so far as a killed turn's (canceled, `worker_stopped`), which the next worker loads; the
|
|
609
|
-
turn's end writes over it.
|
|
437
|
+
question handler, peers, the gate, warm-up, the generation guard), one frozen value given at
|
|
438
|
+
`KernelLoop.new(services:)` or `#attach`. **No turn field goes in Services, no Engine-lifetime one in
|
|
439
|
+
TurnContext.**
|
|
440
|
+
- Specs: `run_context(settings: …)` and `attach_services(kernel, tools: …)` (spec/support/test_kernel.rb).
|
|
441
|
+
- `controller` is an in-process signal; another process stops a worker's turn through the Bridge (`POST cancel` →
|
|
442
|
+
`Engine#cancel_current_turn!`).
|
|
443
|
+
|
|
444
|
+
**The live conversation and the checkpoint.** Both loops register the array they append to on the kernel
|
|
445
|
+
(`KernelLoop#llm_context_turn`, `LLMContextForget::Turn#conversation`, tagged with the run's `turn_id`).
|
|
446
|
+
`Engine#live_conversation` returns it only while a turn runs and once its loop registered it (the kernel never
|
|
447
|
+
clears it; the turn id tells the last turn's from this one's). A worker's drain (`Worker#pending_input_drain`, at
|
|
448
|
+
every iteration boundary) copies it, with the merged lines, into `Session#turn_checkpoint` (`TurnCheckpoint`): every
|
|
449
|
+
save while the turn runs writes that copy with an `interrupted_turn` mark instead of the session's messages, at the
|
|
450
|
+
first boundary and then at most every `TurnCheckpoint::INTERVAL`. `session.messages` itself is untouched until the
|
|
451
|
+
turn ends. The worker then also calls `Engine#checkpoint_metrics`, so `analytics.json` has the running turn's record
|
|
452
|
+
as a killed turn's (canceled, `worker_stopped`); the turn's end writes over it.
|
|
610
453
|
|
|
611
454
|
### SteerCut, GenerationPhase and WaitingSteer
|
|
612
455
|
|
|
613
|
-
The Engine
|
|
614
|
-
|
|
456
|
+
The Engine's one `SteerCut` (`steer_cut.rb`) owns a `GenerationPhase` and a `WaitingSteer`. The Bridge and the TUI
|
|
457
|
+
ask it (`Engine#cut_for_steer`, `#input_epoch`, `#generation_snapshot`), the stream handler feeds it
|
|
615
458
|
(`SteerCut#observe`), a drain that took input calls `#delivered!`, and `release_turn` calls `#finished!`.
|
|
616
459
|
|
|
617
|
-
`GenerationPhase
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
`
|
|
623
|
-
|
|
624
|
-
`Bridge#snapshot` merges into `current_turn.generation`, so a web page joining mid-generation can clock its delivery
|
|
625
|
-
line and tell whether "cut in now" can land.
|
|
626
|
-
|
|
627
|
-
### WaitingSteer
|
|
628
|
-
|
|
629
|
-
A message that may cut the generation but came too early (the generation hadn't been thinking long enough). It
|
|
630
|
-
waits until the thinking passes the model's cut wait (`SteerCut#observe` rechecks it on each thinking chunk), until a
|
|
631
|
-
drain hands the message to the model at a boundary, or until the turn ends (`SteerCut#finished!` clears it).
|
|
460
|
+
- `GenerationPhase`: whether a steer sent as a cut (`/cut TEXT`, `chi send --cut`) may cut the running generation.
|
|
461
|
+
`cut_for_steer` answers `:now`, `:waits` or `:off`. A generation is cuttable while it has streamed only thinking,
|
|
462
|
+
is still thinking, and has thought for at least the model's cut wait (`SteerCutAfter.resolve`). Its `snapshot`
|
|
463
|
+
(`{thinking_only, age}`) goes into the Bridge's `current_turn.generation`, so a page joining mid-generation can
|
|
464
|
+
tell whether "cut in now" can land.
|
|
465
|
+
- `WaitingSteer`: a cut that came too early. It waits until the thinking passes the cut wait (rechecked on each
|
|
466
|
+
thinking chunk), until a drain hands it to the model at a boundary, or until the turn ends.
|
|
632
467
|
|
|
633
468
|
## Events
|
|
634
469
|
|
|
635
470
|
### The turn's sink (`on_event`) and the observers
|
|
636
471
|
|
|
637
472
|
`Engine#emit_event` (`EventFanout#emit_event`, `event_fanout.rb`, which also builds the loops' stream handler and
|
|
638
|
-
emits the working set) sends each turn event to the turn's sink (
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
473
|
+
emits the working set) sends each turn event to the turn's sink (a one-shot's renderer) and then to the persistent
|
|
474
|
+
observers (`Engine#subscribe`), which get a copy with a monotonic `event_seq`: the metrics, the debug log, the
|
|
475
|
+
thinking tails, and in a worker the Bridge ([bridge.md](bridge.md)). **Errors in the sink or an observer are
|
|
476
|
+
caught; they never break the turn.**
|
|
642
477
|
|
|
643
|
-
`Engine#announce` puts an event that belongs to no turn (`ANNOUNCEABLE_EVENTS`: `turn_enqueued`,
|
|
644
|
-
(
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
(`{kind, side}`: a side request's usage, the recap's, `/btw`'s or `ask_model`'s, and the session's side sums) goes
|
|
649
|
-
to the observers the same way from `Engine#note_side_usage` (any thread), without being announceable.
|
|
478
|
+
`Engine#announce` puts an event that belongs to no turn (`Engine::ANNOUNCEABLE_EVENTS`: `turn_enqueued`,
|
|
479
|
+
`turn_dequeued` (`{enqueued_id, client_id}`: the remover's), `card`, `hook_notice`, `guardrail_warning`, `participants_changed`
|
|
480
|
+
([participants](participants.md#the-owners-terminal)), and more) to the observers only. `:side_usage` (a side
|
|
481
|
+
request's usage and the session's side sums) goes to the observers the same way from `Engine#note_side_usage` (any
|
|
482
|
+
thread), without being announceable.
|
|
650
483
|
|
|
651
|
-
Turn events (the main ones; `
|
|
484
|
+
Turn events (the main ones; `events.rb` keeps the shared sets):
|
|
652
485
|
|
|
653
486
|
| Event | When | Payload |
|
|
654
487
|
|---|---|---|
|
|
655
|
-
| `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, at, continue?, resumed?, images?, cut_after}
|
|
656
|
-
| `:reminder_injected` | due reminders went
|
|
657
|
-
| `:context_status` | every request (unless `context.status` is off)
|
|
658
|
-
| `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price, model_ref}` (`
|
|
659
|
-
| `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` (SSE clients get the Bridge's projection
|
|
660
|
-
| `:generation_retrying` |
|
|
488
|
+
| `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, at, continue?, resumed?, images?, cut_after}`, plus `author` / `author_name` / `participant_mode` for a participant |
|
|
489
|
+
| `:reminder_injected` | due reminders went in | `{reminders, at}` |
|
|
490
|
+
| `:context_status` | every request (unless `context.status` is off); outside a turn after `/model`, observers only | [context-telemetry.md](context-telemetry.md) |
|
|
491
|
+
| `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price, model_ref}` (`model_ref` from the model `generate` resolved after the hooks; native adds `profile`, `profile_source`) |
|
|
492
|
+
| `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` (SSE clients get the Bridge's projection) |
|
|
493
|
+
| `:generation_retrying` | a transport retry, or a chat step asked again after its stream dropped (`restarted: true`: what the step streamed is void, and every live view starts it over) | `{iteration, attempt, max_retries, next_delay, error_class, error_message, status, restarted?}` |
|
|
661
494
|
| `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens`, `model_segments` |
|
|
662
|
-
| `:generation_cancelled` |
|
|
663
|
-
| `:thinking_dropped` | the chat loop
|
|
664
|
-
| `:empty_answer_retry` | an empty, cut or malformed answer
|
|
495
|
+
| `:generation_cancelled` | cancelled mid-loop | `{iteration, reason, stopped_by}` |
|
|
496
|
+
| `:thinking_dropped` | the chat loop dropped replayed thinking blocks (`ChatLoop#settle_dropped_thinking!`; anthropic.md) | `{iteration, count, how}` |
|
|
497
|
+
| `:empty_answer_retry` | an empty, cut or malformed answer asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
|
|
665
498
|
| `:steer_cut` | a steer cut the generation | `{iteration, source}` |
|
|
666
499
|
| `:llm_context_edited` | the apply rule applied a batch of LLM context edits (a request, a forget_outputs call, turn end; never the warm-up) | `{moment, why, freed_tokens, tail_tokens, staged, text, groups}` (`LLMContextNotice`) |
|
|
667
|
-
| `:thinking_refused` / `:reasoning_history_refused` | the chat loop
|
|
500
|
+
| `:thinking_refused` / `:reasoning_history_refused` | the chat loop's fallbacks; logged and made a once-per-session-and-host `:hook_notice`, **never reaching the sink** | `{iteration, model, level, detail}` |
|
|
668
501
|
| `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
|
|
669
|
-
| `:tool_dispatch_started` /
|
|
670
|
-
| `:tool_call_started` | before a call runs |
|
|
671
|
-
| `:tool_call_completed` | after a call ran |
|
|
672
|
-
| `:question_requested` /
|
|
673
|
-
| `:plugin_init_wait` | a turn waits for
|
|
502
|
+
| `:tool_dispatch_started` / `_completed` | around a batch | `{iteration, call_count}` |
|
|
503
|
+
| `:tool_call_started` | before a call runs | `{iteration, call_count, call_index, tool, call, params, label, title, called_as, via, view, ref, refs}`; the SSE frame omits `call` |
|
|
504
|
+
| `:tool_call_completed` | after a call ran | `{iteration, call_count, call_index, tool, output, output_truncated, output_file, activity, images, diff, waited_ms, view, ref, refs}` |
|
|
505
|
+
| `:question_requested` / `_answered` / `_cancelled` | `QuestionDesk` (an approval, `ask_user_question`, a hook's `ask_user`, the step-limit Continue) | `{pending_question}` / `{id, answer}` / `{id, reason}` |
|
|
506
|
+
| `:plugin_init_wait` | a turn waits for init tasks that provide tools (`PluginTasks`) | `{tasks: [{bundle, id, label}]}` |
|
|
674
507
|
| `:used_memories_updated` | a memory was read, and after the loop | `{used_memory_names, read_names?}` |
|
|
675
|
-
| `:working_set_updated` | after `:generation_started`
|
|
676
|
-
| `:hook_notice` | a hook's or plugin's notify
|
|
677
|
-
| `:guardrail_warning` | a guardrails line
|
|
678
|
-
| `:turn_completed` | normal end |
|
|
679
|
-
| `:turn_canceled` | Stop, hook stop, Ctrl-C | `{turn_id, cancellation_reason, cancelled_by?, duration_ms}` |
|
|
508
|
+
| `:working_set_updated` | after `:generation_started` and once in `end_turn`, only when the set changed (`Engine#emit_working_set`) | [working-set.md](working-set.md) |
|
|
509
|
+
| `:hook_notice` | a hook's or plugin's notify | `{hook, text, level}` (`between_turns: true` outside a turn) |
|
|
510
|
+
| `:guardrail_warning` | a guardrails line | `{message}` |
|
|
511
|
+
| `:turn_completed` | normal end | `{turn_id, result, turn_summary, display_pending}`, plus `verify`, `verdict`, `revises` when there are; the SSE frame omits `result` |
|
|
512
|
+
| `:turn_canceled` | Stop, hook stop, Ctrl-C, signal | `{turn_id, cancellation_reason, cancelled_by?, duration_ms}` |
|
|
680
513
|
| `:turn_failed` | an error | `{turn_id, error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
|
|
681
|
-
| `:answer_display` | after the after_turn hooks (observers only
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
`
|
|
689
|
-
`TurnOrigin.of` gives `TurnOrigin::NONE` (
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
`
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
`
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
- **The
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
- **
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
`Worker#run_prompt` checks their link and cap (`participant_refusal`: `participants.json`'s entry and cap against
|
|
720
|
-
`SessionMetrics#spend`) before their turn starts and after it ends (the owner's notice, once per cap). A Continue
|
|
721
|
-
keeps the offer turn's author (`ContinueOffer#run_continue_turn`, from `last_turn.author`).
|
|
722
|
-
- **The gate**: `GuardrailWiring#participant_boundary(turn)` is the core check after the load failures, picked by
|
|
723
|
-
the call's `TurnContext#participant_mode`: `Guardrails::PairBoundary` for a pair turn, `Guardrails::ReviewBoundary`
|
|
724
|
-
for a review one. Both read the turn's `author` and are no-ops for the owner's turns; the review boundary denies a participant's turn everything
|
|
725
|
-
outside `Toolkit::REVIEW_TOOLS` (not `forget_outputs`), the pair boundary denies secrets, owner-voiced tools and
|
|
726
|
-
`ask_user_question` and always asks before a write that may land outside the project (docs/guardrails.md).
|
|
727
|
-
The declared tools stay the session's (D3: no rebuild per author).
|
|
728
|
-
- **Hooks and plugins**: `Engine#turn_author` (the running turn's participant author, nil otherwise: the
|
|
729
|
-
published `TurnContext#author`, so any thread reads the running turn's from its first moment) is the hook
|
|
730
|
-
runtime's `turn_author` (`Hooks::Registry#with_runtime` adds `author:` to every event of such a turn) and the
|
|
731
|
-
plugin host's (`ctx.turn_author`).
|
|
732
|
-
- **Spend**: the analytics turn record's `author` feeds `SessionMetrics#spend` / `spend_by_author` (the snapshot's
|
|
733
|
-
`spend_by_author`, `/stats`); `Engine#note_side_usage` stamps a side request's author (`#side_author`: the running
|
|
734
|
-
turn's, the recap's after it, else the owner's) into `SideTotals#by_author`.
|
|
514
|
+
| `:answer_display` | after the after_turn hooks (observers only) | `{display, refs?}` |
|
|
515
|
+
|
|
516
|
+
### Origins and authors
|
|
517
|
+
|
|
518
|
+
A turn's origin is a `TurnOrigin` (`turn_origin.rb`, `client_id`, `enqueued_id`, `author`). `SessionInbox.read_input`
|
|
519
|
+
makes it from an input file; the wakes, reports and reminders make theirs. Ask it, don't read fields
|
|
520
|
+
(`#participant?`, `#participant_author`, `#participant_id`, `#source`, `#human?`). At a boundary it is turned once:
|
|
521
|
+
`#to_h` writes the wire Hash (only the fields set), `TurnOrigin.from` reads one back. **In-process an origin is never
|
|
522
|
+
nil**: `TurnOrigin.of` gives `TurnOrigin::NONE` (an owner's turn with no client) for nil or `{}`; NONE has no wire
|
|
523
|
+
form (`#empty?`, `#wire` nil), so events and hooks carry no origin for it. The boundary events (`:turn_started`,
|
|
524
|
+
`:turn_completed`, `:turn_canceled`, `:turn_failed`) carry `origin` when there is one (`Turn#tag`), with `author:`
|
|
525
|
+
when someone other than the owner wrote it (stamped by chi web from the request's `Web::Principal`). **An owner's turn
|
|
526
|
+
has no `author` anywhere**: not in its input file, its origin or the session.
|
|
527
|
+
|
|
528
|
+
Where a participant's author (`"p:<id>"`; `Steer.participant_author?`) goes (the model is in
|
|
529
|
+
[participants.md](participants.md)):
|
|
530
|
+
|
|
531
|
+
- **The opening message** gets `author` and `author_name` (`Engine#author_fields`; the name from
|
|
532
|
+
`participants.json` at the turn's start, left out when unnamed). Merged input and steers carry the same keys,
|
|
533
|
+
grouped by sender and author so a participant's lines never share the owner's message (`Steer::Line#author`).
|
|
534
|
+
Assistant and tool messages carry none: `Steer.author_at(messages, index)` resolves by position. `ChatLoop#plain`
|
|
535
|
+
keeps the keys (`Steer::AUTHOR_KEYS`).
|
|
536
|
+
- **The wire**: such a message goes out under the review-contribution header (`Steer.wire_text`; prompt-caching.md).
|
|
537
|
+
Saved raw.
|
|
538
|
+
- **`Session#last_turn.author`** and the analytics record's `author` (`SessionMetrics`); a Continue keeps the offer
|
|
539
|
+
turn's author (`ContinueOffer#run_continue_turn`).
|
|
540
|
+
- **Never a command**: a participant's `/word` or `!line` is refused (`Bridge#handle_post_turn` 403,
|
|
541
|
+
`WorkerInbound#queue_as_command` drops it).
|
|
542
|
+
- **Delivery**: a participant's input always waits for a turn of its own (`SessionInbox.waits_for_turn_end?`), so
|
|
543
|
+
the drain never merges it; their turns count against chi web's `pending_limit` (429 `too_many_turns`).
|
|
544
|
+
`Worker#run_prompt` checks their link and cap (`participant_refusal`) before the turn and after it.
|
|
545
|
+
- **The gate**: `GuardrailWiring#participant_boundary` picks `Guardrails::PairBoundary` or
|
|
546
|
+
`Guardrails::ReviewBoundary` by `TurnContext#participant_mode` (guardrails.md). Both are no-ops for the owner.
|
|
547
|
+
The declared tools stay the session's (no rebuild per author).
|
|
548
|
+
- **Hooks and plugins**: `Engine#turn_author` (the published `TurnContext#author`, so any thread reads it from the
|
|
549
|
+
turn's first moment) is the hook runtime's (`author:` on every event of such a turn) and `ctx.turn_author`.
|
|
550
|
+
- **Spend**: `SessionMetrics#spend` / `spend_by_author`; `Engine#note_side_usage` stamps a side request's author
|
|
551
|
+
(`#side_author`) into `SideTotals#by_author`.
|
|
735
552
|
- **Side models**: `IdleRecap::TranscriptFilter` and `Plugin::SideQuestion` name a participant's message as theirs.
|
|
736
|
-
- **The system prompt**: `Engine#refresh_participants!`
|
|
737
|
-
|
|
553
|
+
- **The system prompt**: `Engine#refresh_participants!` rebuilds it when the session gains its first active
|
|
554
|
+
participant or loses its last.
|
|
738
555
|
|
|
739
556
|
### Hook points (not events)
|
|
740
557
|
|
|
741
558
|
`:session_start`, `:before_turn`, `:after_turn` and `:session_end` are hook points: the Engine fires them on the
|
|
742
|
-
hook registry (`@hooks.fire`), not on the sink or the observers. The
|
|
743
|
-
`:after_generation`, `:generation_progress` (Hooks::StreamWatch,
|
|
744
|
-
`:
|
|
559
|
+
hook registry (`@hooks.fire`), not on the sink or the observers. The others are `:before_generation`,
|
|
560
|
+
`:after_generation`, `:generation_progress` (`Hooks::StreamWatch`), `:before_tool_call` (the gate) and
|
|
561
|
+
`:after_tool_call` (ToolRunner). docs/hooks.md lists their payloads.
|
|
745
562
|
|
|
746
563
|
## Session lifecycle
|
|
747
564
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
groups, search, call checks, the unwrap; a source runs the call with its own plugin Context)
|
|
774
|
-
@reminder_store = ReminderStore.new (the kernel gets it), @reminder_queue, @reminders
|
|
775
|
-
wire_kernel: one KernelLoop::Services (Engine-lifetime, never per turn: hooks, tools, reminder_store,
|
|
776
|
-
muted_memory_names, question_handler, peers (PeerView), gate (@guardrail_wiring.gate(hooks:, tools:): values,
|
|
777
|
-
both built by now), warmup (PromptWarmup), generation_guard) →
|
|
778
|
-
@kernel = KernelLoop.new(client:, profile:, model_name:, services:), or a given kernel's #attach(services);
|
|
779
|
-
then sync_kernel_client! (the client is no service: a model switch swaps it, KernelLoop#client=)
|
|
780
|
-
@hooks.runtime = @hook_routes.runtime (HookRoutes, built first: what a hook or plugin does reaches the UIs)
|
|
781
|
-
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
782
|
-
@prompt_builder = SystemPrompt.new(...)
|
|
783
|
-
@session_observer with the metrics, the log subscriber and the thinking tails subscribed (subscribe_session_observers)
|
|
784
|
-
@engine_snapshots (EngineSnapshots: session_state_snapshot and stats_snapshot), built last: nothing reads a
|
|
785
|
-
snapshot while the Engine is built
|
|
786
|
-
|
|
787
|
-
Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
|
|
788
|
-
→ checks the host (ModelProfile.check_host!), sets the choice (ModelBinding#switch!: name and typed name in
|
|
789
|
-
one assignment)
|
|
790
|
-
→ drops the given profile and the profile resolution (resolved again on first need; one that another
|
|
791
|
-
thread finishes for the old model is neither kept nor applied: #store_profile)
|
|
792
|
-
→ sync_model_key! (the next turn's TurnContext takes the keys), @prompt_builder.reset! (the system prompt is
|
|
793
|
-
built again)
|
|
794
|
-
→ sync_kernel_client! (the kernel talks to the new host), the context window cache dropped
|
|
795
|
-
→ the old model's ctx dropped: @metrics.forget_model_reports! (its window and profile reports) and
|
|
796
|
-
@last_context_status = nil; /model (SessionCommands#switch_model) then calls #announce_switched_context!
|
|
797
|
-
(the ctx % on the new model's window, before its first turn; Engine#session= does the configured-window-only
|
|
798
|
-
variant for a session loaded on another model; internals/context-telemetry.md)
|
|
799
|
-
→ the kernel is never rebuilt; the next turn's backend follows the new host's api
|
|
800
|
-
|
|
801
|
-
Model probes (per turn):
|
|
802
|
-
The profile, window, served model and vision are asked from the server's /props as needed.
|
|
803
|
-
The cache is dropped at the turn's end, not its start, so one turn doesn't ask twice.
|
|
804
|
-
A Stop cuts the probes early (Client.swap_probe_cancel).
|
|
805
|
-
```
|
|
565
|
+
`Engine#initialize` (`engine.rb`, the `build_*` methods) wires the collaborators. Read it for the order; what an
|
|
566
|
+
edit must keep:
|
|
567
|
+
|
|
568
|
+
- **Building an Engine makes no network call**: the profile is resolved on first need (`ModelBinding`).
|
|
569
|
+
- `MemoryBundle::SystemBundle.ensure!` runs at build; a failure is logged, not raised.
|
|
570
|
+
- The turn stages are built before the plugins load (`build_tools_and_plugins`: `ExtensionLoad`, the built-in
|
|
571
|
+
`Tools::Builtins.registry`, `ToolkitBinding`, `Tools::ToolIndex`, `PeerSources`, then the plugins; each plugin's
|
|
572
|
+
`ctx` is a `Plugin::Context` over `plugin_host`). After the plugins (or none), `@tool_index.declare(@tools)`
|
|
573
|
+
registers `find_tools` + `call_tool` (source "index", not core), declared per toolkit by `Registry#declared?`
|
|
574
|
+
(`Toolkit::Resolved#declares?` treats built-ins and plugin tools alike).
|
|
575
|
+
- `wire_kernel` gives the kernel one `KernelLoop::Services` (Engine-lifetime: hooks, tools, reminder store, mutes,
|
|
576
|
+
question handler, peers, gate, warm-up, generation guard); the client is no service (a model switch swaps it,
|
|
577
|
+
`sync_kernel_client!`).
|
|
578
|
+
- `@hooks.runtime = @hook_routes.runtime`: what a hook or plugin does reaches the UIs (`HookRoutes`, built first).
|
|
579
|
+
- `EngineSnapshots` is built last: nothing reads a snapshot while the Engine is built.
|
|
580
|
+
|
|
581
|
+
**Model switching** (`Engine#switch_model!`, `engine/models.rb`): checks the host (`ModelProfile.check_host!`),
|
|
582
|
+
`ModelBinding#switch!` (name and typed name in one assignment, the profile and resolution dropped), `sync_model_key!`,
|
|
583
|
+
`@prompt_builder.reset!`, `sync_kernel_client!`, the context window cache dropped, the old model's context reports
|
|
584
|
+
dropped (`@metrics.forget_model_reports!`); `/model` then calls `#announce_switched_context!`
|
|
585
|
+
(context-telemetry.md). **The kernel is never rebuilt**; the next turn's backend follows the new host's api.
|
|
586
|
+
|
|
587
|
+
**Model probes.** The profile, window, served model and vision are asked from the server's `/props` as needed. The
|
|
588
|
+
cache is dropped at the turn's end, not its start, so one turn doesn't ask twice; a Stop cuts the probes
|
|
589
|
+
(`Client.swap_probe_cancel`).
|
|
806
590
|
|
|
807
591
|
## Hooks
|
|
808
592
|
|
|
809
|
-
Plugins and hook files register hooks on hook points (
|
|
810
|
-
|
|
811
|
-
`
|
|
812
|
-
|
|
813
|
-
`
|
|
814
|
-
label => effect map) through `Hooks::Runtime#ask_user` / `Plugin::Host#ask_user`, `Engine#hook_ask_user` and
|
|
815
|
-
`HookRoutes#ask_user` to `QuestionDesk#ask_for_hook`, which turns the effects into the pending question's per-option
|
|
816
|
-
tags. The bundle test kit records the same value (`Testing::Recorder#questions`).
|
|
593
|
+
Plugins and hook files register hooks on hook points (docs/hooks.md). Every fire puts the hook runtime on the
|
|
594
|
+
event: `event[:hook]` (the label) and five helpers, which call the Engine's `Hooks::Runtime` (built by `HookRoutes`,
|
|
595
|
+
`hook_routes.rb`). A plugin's `ctx` has the same ones. A question takes keywords only at that plugin-facing edge;
|
|
596
|
+
from there it is one `HookQuestion` value through `Engine#hook_ask_user` and `HookRoutes#ask_user` to
|
|
597
|
+
`QuestionDesk#ask_for_hook` (the bundle test kit records the same value, `Testing::Recorder#questions`).
|
|
817
598
|
|
|
818
599
|
| Helper | What it does | Used by |
|
|
819
600
|
|---|---|---|
|
|
820
|
-
| `event[:notify].call(text, level:, fallback_for:)` | One line to the user (`:hook_notice`); `fallback_for: :display` marks a line
|
|
821
|
-
| `event[:ask_user].call(question:, options:, header:, allow_freeform:)` | A single-select question
|
|
822
|
-
| `event[:stop_turn].call(reason)` | Cancel the running turn (
|
|
823
|
-
| `event[:stop_generation].call(reason)` | Cut the streaming generation; the turn goes on | loop-guard
|
|
824
|
-
| `event[:steer].call(text)` | Queue text for the running turn's next boundary | check-in
|
|
601
|
+
| `event[:notify].call(text, level:, fallback_for:)` | One line to the user (`:hook_notice`); `fallback_for: :display` marks a line standing in for the answer's display | mcp, loop-guard |
|
|
602
|
+
| `event[:ask_user].call(question:, options:, header:, allow_freeform:)` | A single-select question; nil when there is no one to ask | known-names |
|
|
603
|
+
| `event[:stop_turn].call(reason)` | Cancel the running turn (`:hook`), with a warn notice | loop-guard, check-in |
|
|
604
|
+
| `event[:stop_generation].call(reason)` | Cut the streaming generation; the turn goes on | loop-guard |
|
|
605
|
+
| `event[:steer].call(text)` | Queue text for the running turn's next boundary | check-in, skills |
|
|
825
606
|
|
|
826
607
|
From `:after_turn` and `:session_end` there is no turn left: `stop_turn` and `steer` return false.
|
|
827
608
|
|
|
828
|
-
`:after_turn` adds
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
`
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
`at
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
`
|
|
846
|
-
|
|
847
|
-
`
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
fire it. `QuestionAsked.fire` is a no-op without a subscriber (`Registry#any?`); else it finds the live
|
|
855
|
-
conversation's last `role: "model"` entry (`Engine#live_conversation`, one lookup for both loops: a ChatLoop batch may
|
|
856
|
-
end in a tool result), builds an `AnswerRefs` on that step alone with the question caps (50 / 32 KiB), fires with the
|
|
857
|
-
step's text (`Engine#question_step_text`: the parser's thought and tool-call strip, then
|
|
858
|
-
`OutputFormatter.strip_markup`), a frozen copy of the fields and a no-op `ask_user`, then sets `display_refs` on
|
|
859
|
-
the step **in place** under the event lock (the loops and the forget layer hold entries by identity) and returns the
|
|
860
|
-
fields with `refs:`. `QuestionDesk#request` forwards `refs` past its slice; `PendingQuestion#refs` carries them to
|
|
861
|
-
`:question_requested`, the session file and the cards. `Web::App#with_question_refs` re-validates them (the pending
|
|
862
|
-
question, `cards_for_display`) with the same caps; the page pairs them in the asking slot, on the card and on the
|
|
863
|
-
step's text ([architecture.md](../architecture.md), the `display_refs` paragraph). The kit's
|
|
864
|
-
`Testing::Bundle#question_asked` calls `QuestionAsked.fire` with a one-step conversation.
|
|
609
|
+
**Display-only fields.** `:after_turn` adds `event[:present]` (`AnswerDisplay`, the answer's `display` text) and
|
|
610
|
+
`event[:refs]` (`AnswerRefs`, refs kept as `display_refs`), built on the stored conversation's last message when it
|
|
611
|
+
is the model's. `AnswerDisplay::DISPLAY_ONLY` (`display`, `empty_answer`, `display_refs`) is dropped from every
|
|
612
|
+
model-facing copy (`strip` / `strip_all`: hooks' `messages:`, the recap, `ctx.messages`, after_generation), while
|
|
613
|
+
`KernelLoop#sanitize_history` and `ChatLoop#plain` copy them so the stored conversation keeps them across turns.
|
|
614
|
+
`Web::App#messages_for_display` re-validates `display_refs` (`AnswerRefs.for_display`).
|
|
615
|
+
|
|
616
|
+
**History stamps.** User prompts, merged input and steers, reminders, context and task notes and the model's replies
|
|
617
|
+
carry `at` (`MessageTime::KEY`, `message_time.rb`). A turn reads the wall clock once (`Turn#wall`): `:turn_started`'s
|
|
618
|
+
`at`, its prompt's and its reminders' stamps. The others are stamped by `Steer::Merge#messages` (a merge),
|
|
619
|
+
`ContextNote.message` (a note's `created_at`), `KernelLoop#iterate` / `#cancelled_result` and the chat loop's
|
|
620
|
+
`Run#with_thinking` / `Run#canceled` (each reply): **a new reply path stamps it too**. Tool results, `!cmd` messages and TurnNote notes carry none. **`at`
|
|
621
|
+
must survive the two rebuilds**: both model branches of `KernelLoop#sanitize_history` and `ChatLoop#plain` copy it
|
|
622
|
+
(an allow-list that drops a key loses it for good; `spec/message_time_history_spec.rb` checks both loops). It never
|
|
623
|
+
reaches the wire (prompt-caching.md) but is not in `DISPLAY_ONLY`: hooks and `ctx.messages` see it. Gap dividers
|
|
624
|
+
come from `MessageTime.gap_line`.
|
|
625
|
+
|
|
626
|
+
**`:question_asked`** (`QuestionAsked`, `question_asked.rb`) fires only from the two seams where a question of the
|
|
627
|
+
model's opens: `Engine#request_question` (the kernel's question handler; not non-interactive) and
|
|
628
|
+
`GuardrailWiring#request_approval` (after a stored approval and the non-interactive deny), never from
|
|
629
|
+
`QuestionDesk` (so not `ask_for_hook`, a relayed approval or the step-limit question). It is a no-op without a
|
|
630
|
+
subscriber. Else it finds the live conversation's last model entry (`Engine#live_conversation`), builds an
|
|
631
|
+
`AnswerRefs` on that step with the question caps, fires with `Engine#question_step_text`, and sets `display_refs` on
|
|
632
|
+
the step **in place** under the event lock (the loops and the forget layer hold entries by identity). The refs ride
|
|
633
|
+
`PendingQuestion#refs` to `:question_requested`, the session file and the cards; `Web::App#with_question_refs`
|
|
634
|
+
re-validates them ([architecture.md](../architecture.md), the `display_refs` paragraph).
|
|
865
635
|
|
|
866
636
|
### Hook lifecycle
|
|
867
637
|
|
|
868
|
-
- **Bundle hooks** (`Registry#register_bundle`) and **config hooks** (`register_persistent
|
|
869
|
-
|
|
870
|
-
- **Turn-scoped hooks** (`Engine#register_hook
|
|
871
|
-
|
|
872
|
-
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
line after load takes only the turn / between-turns routes (`#route_by_turn`).
|
|
882
|
-
- Plugin init tasks (`chi.init`, slow setup such as an MCP server's first start): a turn waits for the ones that
|
|
883
|
-
provide tools before it builds its messages, and a Stop or Ctrl-C ends the wait.
|
|
638
|
+
- **Bundle hooks** (`Registry#register_bundle`) and **config hooks** (`register_persistent`) live for the process:
|
|
639
|
+
`clear_all` leaves them.
|
|
640
|
+
- **Turn-scoped hooks** (`Engine#register_hook`) are cleared by `clear_hooks` in `release_turn`.
|
|
641
|
+
- Fire order: bundle hooks (by priority, bundle, hook name), then config hooks, then turn-scoped hooks.
|
|
642
|
+
- **A hook that raises is logged; it never breaks the turn.** A plugin's `chi.on` handler and a bundle's
|
|
643
|
+
`hooks/*.rb` also tell the user once per label (`Hooks.wrap(on_failure:)` → `HookRoutes#plugin_hook_failed`: a
|
|
644
|
+
`:guardrail_warning` labelled `plugins`); the callback reaches the hook files through
|
|
645
|
+
`ExtensionLoad.new(hook_failed:)`. A config hook has none, nor the test kit's sandbox.
|
|
646
|
+
- A notice goes to the running turn's sink and the observers; outside a turn it is announced with
|
|
647
|
+
`between_turns: true`; while the plugins load it is held. `EventRoutes#route` decides it, the same routes as a card's
|
|
648
|
+
(`Engine#show_card`); a guardrails line after load takes only the turn / between-turns routes (`#route_by_turn`).
|
|
649
|
+
- Plugin init tasks (`chi.init`): a turn waits for those that provide tools before it builds its messages; a Stop or
|
|
650
|
+
Ctrl-C ends the wait.
|
|
884
651
|
|
|
885
652
|
## Guardrails
|
|
886
653
|
|
|
887
|
-
`ToolRunner` asks the kernel's `Guardrails::Gate`
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
(`lib/samagotchi/guardrail_wiring.rb`): the load failures first, then the participant's boundary
|
|
895
|
-
(`participant_boundary`, `PairBoundary` for a pair turn else `ReviewBoundary`), then the hook asks, the protected
|
|
896
|
-
paths, the scratch writes, the delegate child's boundary, the rules. That method is the order; it is not spelled
|
|
897
|
-
out here so this page can't drift from it.
|
|
654
|
+
`ToolRunner` asks the kernel's `Guardrails::Gate` before each call with the call's `TurnContext`
|
|
655
|
+
(`Gate#evaluate(call, context:)`); **`GuardrailWiring` keeps no per-turn state**, it makes the guardrails context and
|
|
656
|
+
checks from the turn (`#context(turn)`, `#checks(turn)`). The gate is not itself a hook. It fires the
|
|
657
|
+
`:before_tool_call` hooks (the voters), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
|
|
658
|
+
`event[:blocked]`. A deny stays a deny for the hooks after it; a hook's ask is dropped when guardrails are disabled.
|
|
659
|
+
Then the core checks run on the final call in `GuardrailWiring#checks` order (`guardrail_wiring.rb`). **That method is
|
|
660
|
+
the order**; it is not spelled out here so this page can't drift from it.
|
|
898
661
|
|
|
899
|
-
A gate that raises denies the call
|
|
900
|
-
|
|
901
|
-
The verdict is one of:
|
|
662
|
+
**A gate that raises denies the call.**
|
|
902
663
|
|
|
903
664
|
- **allow**: the call runs.
|
|
904
|
-
- **deny**:
|
|
905
|
-
- **ask**: `Gate#settle_ask`
|
|
906
|
-
|
|
907
|
-
|
|
665
|
+
- **deny**: the output is replaced with denial text; the rest of the batch still runs.
|
|
666
|
+
- **ask**: `Gate#settle_ask` looks for a stored approval first, else `GuardrailWiring#request_approval` asks the user
|
|
667
|
+
through the question flow. A `--non-interactive` run has no one to ask: denied. An approval wider than this once is
|
|
668
|
+
stored.
|
|
908
669
|
|
|
909
|
-
docs/guardrails.md covers the rules and the approval scopes.
|
|
670
|
+
docs/guardrails.md covers the rules, the boundaries and the approval scopes.
|
|
910
671
|
|
|
911
672
|
## Anytime commands
|
|
912
673
|
|
|
913
|
-
A slash command a plugin registers with `anytime: true` (
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
Engine
|
|
918
|
-
├── spawn_anytime { ... } # a thread #shutdown waits for
|
|
919
|
-
├── running_anytime { ... } # cards and notices on this thread are announced at once,
|
|
920
|
-
│ # marked anytime: true, never as the running turn's events
|
|
921
|
-
├── add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed:)
|
|
922
|
-
└── await_init_tasks(controller, on_event)
|
|
923
|
-
```
|
|
924
|
-
|
|
925
|
-
`add_init_task` and `await_init_tasks` belong to plugin init tasks (`PluginTasks`), not to anytime commands.
|
|
674
|
+
A slash command a plugin registers with `anytime: true` (`/btw`) runs on its own thread, even while a turn runs:
|
|
675
|
+
`Engine#spawn_anytime` (a thread `#shutdown` waits for) and `#running_anytime` (cards and notices on that thread are
|
|
676
|
+
announced at once, marked `anytime: true`, **never as the running turn's events**), in `engine/cards.rb`.
|
|
677
|
+
`add_init_task` / `await_init_tasks` belong to plugin init tasks (`PluginTasks`), not to anytime commands.
|
|
926
678
|
|
|
927
679
|
## Shared loop behaviors
|
|
928
680
|
|
|
929
|
-
Both loops:
|
|
930
|
-
|
|
931
|
-
- <a id="a-reply-that-is-only-a-tool-call"></a>**A reply that is only a tool call** (`LLM::StrayToolCall`,
|
|
932
|
-
calls whose whole text is one tool-call block closed by the tag that opened it
|
|
933
|
-
`</function>`, `<
|
|
934
|
-
|
|
935
|
-
guessed from the block and it never enters the history (`:generation_completed`
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
`
|
|
949
|
-
every UI words the notice
|
|
950
|
-
- **A cut generation** (`after_cut`): a
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
- **Context status**: an estimate of the window's fill before each request,
|
|
960
|
-
emitted on every request (`:context_status`) so the UI always has a current
|
|
961
|
-
number, with a guidance line on the tail only when it rises far enough.
|
|
681
|
+
Both loops, unless marked:
|
|
682
|
+
|
|
683
|
+
- <a id="a-reply-that-is-only-a-tool-call"></a>**A reply that is only a tool call** (`LLM::StrayToolCall`, chat loop
|
|
684
|
+
only): a reply with no tool calls whose whole text is one tool-call block closed by the tag that opened it
|
|
685
|
+
(`<function=` by `</function>`, `<parameter=` by `</parameter>` or `</function>`, `<tool_call>` by
|
|
686
|
+
`</tool_call>`; inner tags not needed) isn't an answer. Prose or a fence before it, or anything after the close,
|
|
687
|
+
keeps it an answer. Nothing is guessed from the block and it never enters the history (`:generation_completed` and
|
|
688
|
+
`:after_generation` have already seen the text): `:generation_retrying`
|
|
689
|
+
with `restarted` voids the streamed text, `:empty_answer_retry` with `malformed` goes out, and a hidden
|
|
690
|
+
`TurnNote.malformed_retry` asks again at the retry temperature. Its own budget (`retry.empty_answer: 0` doesn't turn
|
|
691
|
+
it off; a real tool call resets it); two in a row fail the turn with `LLM::MalformedGeneration`, keeping its
|
|
692
|
+
earlier steps. A Stop on a stray reply ends the turn cancelled, not failed. Logged `generation_malformed`.
|
|
693
|
+
`KernelLoop` has its own path (`ToolCallParser#malformed`).
|
|
694
|
+
- **Empty-answer retry** (`EmptyAnswerRetry`): no visible text and no tool calls is asked again, up to
|
|
695
|
+
`retry.empty_answer` times (at most 3), with a hidden `TurnNote.empty_retry`, at temperature 0.6 only when the
|
|
696
|
+
configured sampling sets none. A length stop with the context ≥ 90% full is not retried. Out of retries, the turn
|
|
697
|
+
ends with a `TurnNote.empty` note. A Stop on an empty reply ends the turn cancelled.
|
|
698
|
+
- **A refusal** (chat loop only): finish reason `refusal`, or `content_filter` with no text, is an empty answer never
|
|
699
|
+
retried; text streamed before it is not kept and tool calls with it don't run. A `content_filter` with text keeps
|
|
700
|
+
it, marked `ChatLoop::FILTERED_MARK`, also never retried. `ModelResult#refusal` (`{category:}` or `{}`) goes on the `TurnNote.empty`
|
|
701
|
+
marker and `turn_summary.empty_answer`, so every UI words the notice the same.
|
|
702
|
+
- **A cut generation** (`after_cut`): a plugin's cut (`stop_generation`) spends the same retry budget with its own
|
|
703
|
+
nudge; used up, the turn ends cancelled (`:hook`). A steer's cut asks again with the steer and spends nothing.
|
|
704
|
+
- **Steering**: queued input goes in at iteration boundaries (`Steer.inject!`, `:pending_input_merged`). A worker's
|
|
705
|
+
drain takes every input file in name order except those `SessionInbox.waits_for_turn_end?` leaves for a turn of
|
|
706
|
+
their own (`delivery: queue`, images): skipped, **not a barrier**, so a line sent after them still merges. Only
|
|
707
|
+
the claimed files reach `:input_merged`, the failure hand-back and the checkpoint; the skipped ones run after the
|
|
708
|
+
turn, one turn each (`Worker#run_input_files`).
|
|
709
|
+
- **Context status**: an estimate before every request (`:context_status`), a guidance line on the tail only when
|
|
710
|
+
it rises far enough.
|
|
962
711
|
- **Cancel**: text streamed before a cancel stays, marked `[interrupted]`.
|
|
963
|
-
- **The same events
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
`
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
The prefill is kept in the model message so the next prompt starts with what the server has cached; each
|
|
972
|
-
generation's `ThoughtStreamSplitter` is seeded with it (an open thought's reasoning streams as `thinking:`), and the
|
|
973
|
-
answer is stripped from prefill + response, so the reasoning never lands in it).
|
|
712
|
+
- **The same events** and generation / tool call hooks.
|
|
713
|
+
|
|
714
|
+
Native loop only: the Qwen open-block recovery, and the thinking prefill (`Thinking.native`,
|
|
715
|
+
`KernelLoop#native_prefill`, once per turn): with thinking off, Qwen gets `QWEN_EMPTY_THOUGHT` and Gemma 4
|
|
716
|
+
`GEMMA_EMPTY_THOUGHT` (no `<|think|>` token); otherwise Qwen gets `QWEN_OPEN_THOUGHT` when its chat template writes
|
|
717
|
+
one (`Thinking.opens_thought?`). The prefill is kept in the model message so the next prompt starts with what the
|
|
718
|
+
server cached; each generation's `ThoughtStreamSplitter` is seeded with it, so the reasoning never lands in the
|
|
719
|
+
answer.
|
|
974
720
|
|
|
975
721
|
## Key design principles
|
|
976
722
|
|
|
977
|
-
1. **Turn state is cross-thread safe.** TurnState uses one Monitor and calls nothing out while it is held
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
`[chat, thinking, layers]` (chat/native, the thinking level, the turn's LLM context layers), and is reset
|
|
987
|
-
only on a model switch, a tools change, a plugin's `prompt_changed!` (`Engine#prompt_changed!`), a profile
|
|
988
|
-
change or a toolkit change (`Engine#apply_toolkit!`). The stable part (base prompt, the toolkit's not-loaded line,
|
|
989
|
-
rg guidance, identity, model notes, preloaded memories, AGENTS.md, appended files, the toolkit's kit prompt, the
|
|
990
|
-
plugins' prompt sections, memory indexes) comes before the volatile tail (model, working directory, session).
|
|
991
|
-
prompt-caching.md covers this in detail.
|
|
723
|
+
1. **Turn state is cross-thread safe.** TurnState uses one Monitor and calls nothing out while it is held; other
|
|
724
|
+
threads read the turn only through `Engine#turn_context` / `#last_turn_context`.
|
|
725
|
+
2. **Turn-scoped and process-wide hooks.** `register_hook` hooks are cleared at `release_turn`; bundle and config
|
|
726
|
+
hooks survive.
|
|
727
|
+
3. **Each loop works on its own copy of the conversation** (see "The model↔tool loop").
|
|
728
|
+
4. **Events are the contract.** UIs read events, not internal state. `on_event` is per turn; observers persist.
|
|
729
|
+
5. **A stable system prompt for the cache.** `SystemPrompt#build` caches per Engine, keyed by
|
|
730
|
+
`[chat, thinking, layers]`, reset only on a model switch, a tools change, `Engine#prompt_changed!`, a profile
|
|
731
|
+
change or `Engine#apply_toolkit!`. The section order and the stable/volatile split are prompt-caching.md's.
|
|
992
732
|
|
|
993
733
|
## Key files
|
|
994
734
|
|
|
995
|
-
Paths are under `lib/samagotchi/`.
|
|
735
|
+
Paths are under `lib/samagotchi/`. The session manager, the Worker, the Bridge, task watches and guardrails files
|
|
736
|
+
are in [architecture.md](../architecture.md)'s file table.
|
|
996
737
|
|
|
997
738
|
| File | Role |
|
|
998
739
|
|---|---|
|
|
999
|
-
| `engine.rb` |
|
|
1000
|
-
| `engine/steering.rb` | `Engine::SteeringPart`:
|
|
1001
|
-
| `engine/side_requests.rb` | `Engine::SideRequestsPart`: ctx.ask_model's and Check this's side requests,
|
|
1002
|
-
| `engine/recap.rb` | `Engine::RecapPart`: the idle recap
|
|
1003
|
-
| `engine/models.rb` | `Engine::ModelsPart`:
|
|
1004
|
-
| `engine/model_settings.rb` | `Engine::ModelSettingsPart`:
|
|
1005
|
-
| `engine/events.rb` | `Engine::EventsPart`: observers, `#announce`, the event lock, held announcements
|
|
1006
|
-
| `engine/cards.rb` | `Engine::CardsPart`: `#show_card`, anytime commands
|
|
1007
|
-
| `engine/objectives.rb` | `Engine::ObjectivesPart`: the objectives
|
|
1008
|
-
| `engine/messages.rb` | `Engine::MessagesPart`:
|
|
1009
|
-
| `engine/plugins.rb` | `Engine::PluginsPart`:
|
|
1010
|
-
| `engine/hooks.rb` | `Engine::HooksPart`: turn-scoped hooks, the hook runtime
|
|
1011
|
-
| `engine/guardrails.rb` | `Engine::GuardrailsPart`: the guardrails' readers
|
|
740
|
+
| `engine.rb` | The wiring (`initialize`, `build_*`, the kernel), the turn skeleton (`run_turn`, `begin_turn`, `prepare_turn`, `turn_messages`, `generate`, `end_turn`), `shutdown` |
|
|
741
|
+
| `engine/steering.rb` | `Engine::SteeringPart`: steers, the drains, the cut, the activity clock, the cancel |
|
|
742
|
+
| `engine/side_requests.rb` | `Engine::SideRequestsPart`: ctx.ask_model's and Check this's side requests, side usage, System 1's recording (`system_one_recorder`) |
|
|
743
|
+
| `engine/recap.rb` | `Engine::RecapPart`: the idle recap and `/recap` |
|
|
744
|
+
| `engine/models.rb` | `Engine::ModelsPart`: model names, identity, notes, `switch_model!`, the profile, `backend` |
|
|
745
|
+
| `engine/model_settings.rb` | `Engine::ModelSettingsPart`: SessionModelSettings and EngineSnapshots, forwarded |
|
|
746
|
+
| `engine/events.rb` | `Engine::EventsPart`: observers, `#announce`, the event lock, held announcements |
|
|
747
|
+
| `engine/cards.rb` | `Engine::CardsPart`: `#show_card`, anytime commands |
|
|
748
|
+
| `engine/objectives.rb` | `Engine::ObjectivesPart`: the objectives stage's builder and forwards (`announce_objectives`, `show_objective_proposals`) |
|
|
749
|
+
| `engine/messages.rb` | `Engine::MessagesPart`: out-of-turn message edits, checkpoints, the live conversation |
|
|
750
|
+
| `engine/plugins.rb` | `Engine::PluginsPart`: plugins, init tasks, staged tools, `Plugin::Host` |
|
|
751
|
+
| `engine/hooks.rb` | `Engine::HooksPart`: turn-scoped hooks, the hook runtime, reminders |
|
|
752
|
+
| `engine/guardrails.rb` | `Engine::GuardrailsPart`: the guardrails' readers, the first turn's load warnings |
|
|
1012
753
|
| `engine/participants.rb` | `Engine::ParticipantsPart`: the turn's author, a pair turn's stop, the participants line |
|
|
1013
|
-
| `engine/session.rb` | `Engine::SessionPart`: `#session=`, the state dir, the relay's view, the system prompt
|
|
754
|
+
| `engine/session.rb` | `Engine::SessionPart`: `#session=`, the state dir, the relay's view, the system prompt |
|
|
1014
755
|
| `engine/verify.rb` | `Engine::VerifyPart`: the Verify/Revise anchors' stage builder |
|
|
1015
756
|
| `engine/questions.rb` | `Engine::QuestionsPart`: ask-user-question (QuestionDesk, forwarded) |
|
|
1016
|
-
| `engine/toolkit.rb` | `Engine::ToolkitPart`: the toolkit, `ctx.load_tools`,
|
|
1017
|
-
| `engine/context_judge.rb` | `Engine::ContextJudgePart`: the candidate judge's
|
|
1018
|
-
| `
|
|
1019
|
-
| `engine/
|
|
1020
|
-
| `
|
|
1021
|
-
| `
|
|
1022
|
-
| `
|
|
1023
|
-
| `
|
|
1024
|
-
| `
|
|
1025
|
-
| `
|
|
1026
|
-
| `
|
|
1027
|
-
| `generation_phase.rb`, `waiting_steer.rb` |
|
|
1028
|
-
| `
|
|
1029
|
-
| `
|
|
1030
|
-
| `
|
|
1031
|
-
| `
|
|
1032
|
-
| `
|
|
1033
|
-
| `
|
|
1034
|
-
| `
|
|
1035
|
-
| `
|
|
1036
|
-
| `
|
|
1037
|
-
| `
|
|
1038
|
-
| `
|
|
1039
|
-
| `
|
|
1040
|
-
| `
|
|
1041
|
-
| `
|
|
1042
|
-
| `
|
|
1043
|
-
| `
|
|
1044
|
-
| `
|
|
1045
|
-
| `reasoning_history.rb` | `ReasoningHistory`: the levels, `.resolve`, `.run_start` |
|
|
1046
|
-
| `llm/backend.rb` | The `LLM::ModelBackend` contract (`#complete(...)`) |
|
|
1047
|
-
| `llm/native_backend.rb` | Wraps KernelLoop as a backend (`#complete` calls `#run`) |
|
|
1048
|
-
| `llm/model_result.rb` | `LLM::ModelResult`, what a backend returns |
|
|
1049
|
-
| `tool_runner.rb` | Per-call path (gate, start/complete events, output cap, images, diffs) |
|
|
1050
|
-
| `tool_output_cut.rb`, `tool_output_files.rb` | `ToolOutputCut`: how a tool's output longer than `max_tool_output_chars` (a `memory_read`'s: `memory.read_max_chars`) is cut, by the tool's policy (`POLICIES`; `:head` for a tool with none); `ToolOutputFiles`: the whole output of an execute cut that way, saved in the session's `tool_output/` folder ([tool-output-limits.md](tool-output-limits.md)) |
|
|
1051
|
-
| `tool_row_fields.rb`, `row_refs.rb` | `ToolRowFields`: a tool row's fields beyond its params (`ACTIVITY_KEYS`, `EVENT_KEYS`), the same live and on a reload; `RowRefs`: a plugin tool's row refs (`refs:`), checked, capped and saved with the result |
|
|
1052
|
-
| `tool_response.rb` | Per-batch dispatch and tool_response entries (joined for native, single for chat) |
|
|
1053
|
-
| `empty_answer_retry.rb` | The empty-answer and cut retry budget |
|
|
1054
|
-
| `prompt_warmup.rb` | The turn-end warm-up |
|
|
1055
|
-
| `tools/registry.rb` | The tools a session offers: schemas, handlers, order |
|
|
1056
|
-
| `tools/tool_index.rb` | `Tools::ToolIndex`: `find_tools` and `call_tool`, the tools a session knows but doesn't declare one by one (the sources' groups and the built-ins its toolkit leaves out), `#declare`, `#refresh!`, `#unwrap` |
|
|
1057
|
-
| `plugin/tool_spec.rb` | `Plugin::ToolSpec`: a checked tool declaration (`chi.tool`, a tool set, `ToolSpec.build`), what `Registry#register` gets; `#per_call` |
|
|
1058
|
-
| `system_prompt.rb` | System prompt construction (stable part, volatile tail) |
|
|
1059
|
-
| `prompt_files.rb`, `model_notes.rb` | The AGENTS.md chain and appended files; the model notes the prompt loads |
|
|
1060
|
-
| `toolkit.rb`, `toolkit_command.rb` | A session's toolkit (`Toolkit#resolve`, what `Registry` declares); `/toolkit` |
|
|
1061
|
-
| `toolkit_binding.rb` | `ToolkitBinding`: the Engine's toolkit seam (the kits, `#explained`, `ctx.use_toolkit`, the turn-start `#apply!` behind `Engine#apply_toolkit!`) |
|
|
1062
|
-
| `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
|
|
1063
|
-
| `hook_routes.rb` | `HookRoutes`: the Engine's routes for a hook's or plugin's notice (`:hook_notice`, `fallback_for:`), question, stop and steer, and the guardrails' after-load line |
|
|
1064
|
-
| `question_asked.rb` | `QuestionAsked`: the `:question_asked` fire, the asking step's refs (`display_refs` in place, the pending question's `refs`) |
|
|
1065
|
-
| `question_desk.rb`, `pending_question.rb`, `question_answer.rb` | `QuestionDesk`: the one open question (ask, post, answer, cancel, relay mark); `PendingQuestion` and `QuestionAnswer`, its values (Hashes only in events, Bridge JSON, the session file and the hook/plugin `ask_user` result) |
|
|
1066
|
-
| `hook_question.rb` | `HookQuestion`: a hook's or plugin's `ask_user` question as one value, from the runtime/host edge to `QuestionDesk#ask_for_hook` |
|
|
1067
|
-
| `event_routes.rb` | `EventRoutes`: where a card (`Engine#show_card`), a notice (`HookRoutes#notify`) and a guardrails line (`#guardrail_notify`, turn or between turns only) go: held while the plugins load, an anytime command's, an init task's, a running turn's event, else announced or held for `holding_announcements` |
|
|
1068
|
-
| `engine_peers.rb`, `peer_sources.rb` | `Engine::PeerView` (what the kernel's peer tools see of the Engine) and `Engine::RelayPeer` (a delegate's approval relay); `PeerSources`: the attached context sources a peer bundle declares as its peer's answers (`chi.peer_source`) |
|
|
1069
|
-
| `extension_load.rb` | What an Engine loads at start: config.yml and bundle hooks, plugins, `bundles:` settings; events held while plugins load |
|
|
1070
|
-
| `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context and approvals; the verdict per call |
|
|
1071
|
-
| `events.rb` | Event type sets shared by the Worker, the Bridge, the TUI and the web |
|
|
1072
|
-
| `cancellation_controller.rb` | A turn's cancel signal, and a generation's child controller |
|
|
1073
|
-
| `session.rb` | Session model (status, messages, persistence) |
|
|
1074
|
-
| `turn_checkpoint.rb` | `TurnCheckpoint`: a worker's running turn saved at the loop's iteration boundaries (`Session#turn_checkpoint`), so a killed worker loses at most `INTERVAL` of it |
|
|
1075
|
-
| `context_status.rb` | `ContextStatus`: one turn's context tracking (the status line's value, the `:context_status` event, the model's guidance line) ([context-telemetry.md](context-telemetry.md)) |
|
|
1076
|
-
| `session_manager.rb` | Background session processes (workers) and their IPC |
|
|
1077
|
-
| `worker_wakes.rb` | `WorkerWakes`: a worker's wake budget for the turns nobody typed (`session.max_wakes`) |
|
|
1078
|
-
| `task_watch.rb`, `task_finish_note.rb` | `TaskWatch`: the background tasks a worker's session started that the model hasn't seen finish; `TaskFinishNote`: the note one leaves when it does ([background-tasks.md](background-tasks.md#finish-wake)) |
|
|
1079
|
-
| `side_usage.rb` | `SideUsage`: one side request's usage (a recap, `ctx.ask_model`), folded into the session's side sums (`:side_usage`) |
|
|
1080
|
-
| `bridge/client_event.rb` | `Bridge::ClientEvent.project`: what an SSE client gets for an engine event ([bridge.md](bridge.md)) |
|
|
1081
|
-
| `guardrails/parent_policy.rb` | `Guardrails::ParentPolicy`: which options of a question a parent agent may pick (`guardrails.parent_answers`) |
|
|
757
|
+
| `engine/toolkit.rb` | `Engine::ToolkitPart`: the toolkit, `ctx.load_tools`, the toolkit suggestion's builder |
|
|
758
|
+
| `engine/context_judge.rb` | `Engine::ContextJudgePart`: the candidate judge's builder and `judge_late_candidates` |
|
|
759
|
+
| `engine/turn_helpers.rb` | `Engine::TurnHelpersPart`: turn settings, images, model ref, used memories, the warm-up's builder |
|
|
760
|
+
| `engine/turn_end.rb` | `Engine::TurnEndPart`: `complete_turn`, release, what an ending keeps, `last_turn`, the answer's display |
|
|
761
|
+
| `turn_stages/*.rb`, `system_one/recorder.rb` | The turn stages and their recorder ("Turn stages") |
|
|
762
|
+
| `turn_context.rb`, `turn_state.rb`, `turn_endings.rb`, `turn_origin.rb`, `turn_checkpoint.rb` | `TurnContext`; cross-thread turn state; the endings' builders; `TurnOrigin`; the running turn's checkpoint |
|
|
763
|
+
| `event_fanout.rb`, `events.rb`, `event_routes.rb` | `EventFanout` (sink + observers, the stream handler, the working set's emit); the shared event sets; where a card, notice or guardrails line goes |
|
|
764
|
+
| `engine_snapshots.rb` | `session_state_snapshot` (cheap, never raises), `stats_snapshot` (may probe `/props`) |
|
|
765
|
+
| `model_binding.rb`, `effective_model.rb`, `session_model_settings.rb`, `session_setting.rb` | The model choice and profile; `EffectiveModel` / `ModelChoice`; the session's settings on its model; the per-session setting descriptors |
|
|
766
|
+
| `model_lookup.rb`, `host_registry.rb`, `host_address.rb` | `ModelLookup`, every setting layer's input; `HostRegistry` (`.current`, the shared one); `HostAddress`, whether an address is remote |
|
|
767
|
+
| `thinking_notices.rb`, `level_setting_command.rb`, `thinking_command.rb`, `reasoning_history_command.rb`, `reasoning_history.rb` | Thinking notices; `/thinking`, `/reasoning-history`; `ReasoningHistory` (`.resolve`, `.run_start`) |
|
|
768
|
+
| `steer.rb`, `steer_cut.rb`, `generation_phase.rb`, `waiting_steer.rb`, `steer_cut_after.rb`, `cut_policy.rb` | Steers at boundaries; the cut seam and its parts; the cut wait; the loops' cut path |
|
|
769
|
+
| `kernel_loop.rb` | The native loop |
|
|
770
|
+
| `reprefill_meter.rb` | `ReprefillMeter`: the Engine's one re-prefill baseline, given to both loops (prompt-caching.md, "Reading the numbers") |
|
|
771
|
+
| `llm/chat_loop.rb`, `llm/adapters.rb`, `llm/openai_chat.rb`, `llm/adapter_common.rb`, `llm/refusal_memo.rb` | The chat loop; `BY_API`; the OpenAI adapter; what both adapters share; refusals remembered |
|
|
772
|
+
| `llm/anthropic_messages.rb`, `llm/anthropic_wire.rb`, `llm/anthropic_assembly.rb`, `llm/dialect.rb`, `llm/prompt_cache.rb` | The Messages adapter ([anthropic.md](anthropic.md)); cache breakpoints |
|
|
773
|
+
| `llm/backend.rb`, `llm/native_backend.rb`, `llm/model_result.rb`, `llm/turn_settings.rb` | `ModelBackend`; `NativeBackend`; `ModelResult`; `TurnSettings` |
|
|
774
|
+
| `llm/native_tool_normalizer.rb`, `llm/stream_drops.rb`, `llm/stray_tool_call.rb` | Adapter tool calls as chi's; a dropped stream asked again; a reply that is only a tool call |
|
|
775
|
+
| `tool_runner.rb`, `tool_response.rb`, `tool_row_fields.rb`, `row_refs.rb` | Per call; per batch; row fields; plugin row refs |
|
|
776
|
+
| `tool_output_cut.rb`, `tool_output_files.rb` | The output cap's policies; an execute's saved whole output ([tool-output-limits.md](tool-output-limits.md)) |
|
|
777
|
+
| `tools/tool_surface.rb`, `tools/registry.rb`, `tools/tool_index.rb`, `plugin/tool_spec.rb` | The turn's surface; the Registry; `find_tools` / `call_tool`; a checked plugin tool declaration |
|
|
778
|
+
| `toolkit.rb`, `toolkit_command.rb`, `toolkit_binding.rb` | Toolkits; `/toolkit`; the Engine's toolkit seam (`#published`, `#apply!`) |
|
|
779
|
+
| `empty_answer_retry.rb`, `prompt_warmup.rb` | The empty-answer and cut retry budget; the turn-end warm-up |
|
|
780
|
+
| `system_prompt.rb`, `prompt_files.rb`, `model_notes.rb` | The system prompt; AGENTS.md and appended files; model notes |
|
|
781
|
+
| `hooks/registry.rb`, `hook_routes.rb`, `hook_question.rb`, `extension_load.rb` | Hook registry and `Hooks::Runtime`; the hook routes; `HookQuestion`; what an Engine loads at start |
|
|
782
|
+
| `question_desk.rb`, `pending_question.rb`, `question_answer.rb`, `question_asked.rb` | The one open question and its values; `:question_asked` |
|
|
783
|
+
| `engine_peers.rb`, `peer_sources.rb` | `Engine::PeerView`, `Engine::RelayPeer`; `PeerSources` |
|
|
784
|
+
| `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context, checks and approvals; the verdict per call |
|
|
785
|
+
| `cancellation_controller.rb`, `context_status.rb`, `side_usage.rb`, `degrade.rb` | A turn's cancel signal; context tracking ([context-telemetry.md](context-telemetry.md)); a side request's usage; `Degrade` |
|