samagotchi 0.154.1 → 0.155.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 +16 -1
- data/docs/architecture.md +3 -2
- data/docs/bundles.md +21 -0
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +1 -1
- data/docs/internals/anthropic.md +4 -4
- data/docs/internals/attached-context.md +8 -6
- data/docs/internals/block-renderers.md +8 -1
- data/docs/internals/engine.md +220 -99
- data/docs/internals/inline-widgets.md +136 -0
- data/docs/internals/objectives.md +8 -6
- data/docs/internals/participants.md +5 -5
- data/docs/internals/prompt-caching.md +9 -5
- data/docs/internals/web-frontend.md +1 -1
- data/docs/internals/working-set.md +2 -1
- data/docs/plugins.md +2 -0
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +2 -2
- data/docs/testing.md +1 -1
- data/lib/samagotchi/block_renderers.rb +5 -0
- data/lib/samagotchi/bundle_command/check.rb +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
- data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine/context_judge.rb +19 -125
- data/lib/samagotchi/engine/guardrails.rb +4 -2
- data/lib/samagotchi/engine/messages.rb +9 -6
- data/lib/samagotchi/engine/objectives.rb +22 -90
- data/lib/samagotchi/engine/participants.rb +15 -10
- data/lib/samagotchi/engine/plugins.rb +3 -1
- data/lib/samagotchi/engine/questions.rb +1 -1
- data/lib/samagotchi/engine/side_requests.rb +5 -2
- data/lib/samagotchi/engine/toolkit.rb +33 -190
- data/lib/samagotchi/engine/turn_end.rb +1 -1
- data/lib/samagotchi/engine/turn_helpers.rb +16 -39
- data/lib/samagotchi/engine/verify.rb +7 -143
- data/lib/samagotchi/engine.rb +162 -99
- data/lib/samagotchi/engine_peers.rb +1 -1
- data/lib/samagotchi/event_fanout.rb +18 -12
- data/lib/samagotchi/guardrail_wiring.rb +83 -82
- data/lib/samagotchi/guardrails/gate.rb +41 -40
- data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
- data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
- data/lib/samagotchi/guardrails/targets.rb +6 -4
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/inline_widgets.rb +325 -0
- data/lib/samagotchi/kernel_loop.rb +190 -213
- data/lib/samagotchi/llm/backend.rb +3 -1
- data/lib/samagotchi/llm/chat_loop.rb +63 -41
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/llm_context_forget.rb +5 -3
- data/lib/samagotchi/model_binding.rb +3 -3
- data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
- data/lib/samagotchi/question_desk.rb +1 -1
- data/lib/samagotchi/revise_anchor.rb +1 -1
- data/lib/samagotchi/system_prompt.rb +37 -30
- data/lib/samagotchi/testing/boundary.rb +7 -0
- data/lib/samagotchi/testing/bundle.rb +7 -5
- data/lib/samagotchi/testing/install.rb +3 -2
- data/lib/samagotchi/tool_activity.rb +8 -6
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tool_response.rb +4 -3
- data/lib/samagotchi/tool_runner.rb +91 -100
- data/lib/samagotchi/toolkit_binding.rb +40 -14
- data/lib/samagotchi/tools/deferred_tools.rb +17 -21
- data/lib/samagotchi/tools/registry.rb +43 -40
- data/lib/samagotchi/tools/tool_index.rb +135 -88
- data/lib/samagotchi/tools/tool_surface.rb +173 -0
- data/lib/samagotchi/turn_context.rb +67 -0
- data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
- data/lib/samagotchi/turn_stages/objectives.rb +146 -0
- data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
- data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
- data/lib/samagotchi/turn_stages/warmup.rb +83 -0
- data/lib/samagotchi/turn_state.rb +46 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/markdown_renderer.rb +39 -14
- data/lib/samagotchi/web/message_display.rb +1 -1
- data/lib/samagotchi/web/public/app.js +5 -1
- data/lib/samagotchi/web/public/block_host.js +74 -199
- data/lib/samagotchi/web/public/block_renderers.css +2 -0
- data/lib/samagotchi/web/public/block_source.js +7 -4
- data/lib/samagotchi/web/public/block_watch.js +256 -0
- data/lib/samagotchi/web/public/composer_fold.js +3 -0
- data/lib/samagotchi/web/public/index.html +28 -9
- data/lib/samagotchi/web/public/inline_widgets.js +146 -0
- data/lib/samagotchi/web/public/owner_verify.js +1 -1
- data/lib/samagotchi/web/public/participant.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +1 -1
- data/lib/samagotchi/web/public/stage_watch.js +6 -2
- data/lib/samagotchi/web/public/text_roots.js +1 -1
- data/lib/samagotchi/web/public/verify_target.js +1 -1
- data/lib/samagotchi/web/public/widget_picks.js +130 -0
- data/lib/samagotchi/web/public/widget_walk.js +230 -0
- data/lib/samagotchi/web/public/widgets.css +70 -0
- metadata +17 -1
data/docs/internals/engine.md
CHANGED
|
@@ -6,12 +6,17 @@ and the attached TUI, a `--non-interactive` one-shot) runs turns the same way wi
|
|
|
6
6
|
they render.
|
|
7
7
|
|
|
8
8
|
```
|
|
9
|
-
Engine#run_turn → backend.complete(
|
|
10
|
-
→ per iteration: one generation, then ToolResponse.run_batch over the calls
|
|
11
|
-
→ ToolRunner#run per call → Guardrails::Gate
|
|
9
|
+
Engine#run_turn → backend.complete(..., context:) → KernelLoop#run (native) or ChatLoop#complete (chat)
|
|
10
|
+
→ per iteration: one generation, then ToolResponse.run_batch(context:) over the calls
|
|
11
|
+
→ ToolRunner#run(call, context:) per call → Guardrails::Gate#evaluate(call, context:)
|
|
12
|
+
→ KernelLoop#dispatch_tool_call(call, context) → the handler's ToolContext(kernel, context)
|
|
12
13
|
→ repeat until the model answers without tool calls, or the iteration limit
|
|
13
14
|
```
|
|
14
15
|
|
|
16
|
+
`context` is the turn's `TurnContext` (`lib/samagotchi/turn_context.rb`, see "Turn and TurnContext" below): every
|
|
17
|
+
step of the tool path takes it as an argument; none of them reads the turn from the kernel, the wiring or the
|
|
18
|
+
Engine's ivars.
|
|
19
|
+
|
|
15
20
|
## Quick reference: the turn lifecycle
|
|
16
21
|
|
|
17
22
|
```
|
|
@@ -19,61 +24,63 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
19
24
|
pending_input:, continue:, origin:, images:, id:, resumed:, resume_from:, input_waiting:)
|
|
20
25
|
│
|
|
21
26
|
├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id:, input_waiting:)
|
|
27
|
+
│ # a Verify turn's claim checked first: @verify_anchors.verified(session, anchor) (nil when its
|
|
28
|
+
│ # quote isn't in the source session's answer)
|
|
22
29
|
│ # (then, on a continue, turn.resumed = resumed (a step-limit Continue) and
|
|
23
30
|
│ # turn.resume_from = resume_from (its run's start; nil: it starts cold))
|
|
24
31
|
│ # @session = session, session.status = RUNNING, used memories absorbed,
|
|
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
|
|
32
42
|
│
|
|
33
43
|
├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
|
|
34
44
|
│
|
|
35
|
-
├── prepare_turn(turn, images)
|
|
36
|
-
│ #
|
|
37
|
-
│ #
|
|
38
|
-
│ #
|
|
39
|
-
│ #
|
|
40
|
-
│ #
|
|
41
|
-
│ #
|
|
42
|
-
│
|
|
43
|
-
│ #
|
|
44
|
-
│ #
|
|
45
|
-
│
|
|
46
|
-
│
|
|
47
|
-
│ #
|
|
48
|
-
│ #
|
|
49
|
-
│
|
|
50
|
-
│
|
|
51
|
-
│ #
|
|
52
|
-
│ #
|
|
53
|
-
│
|
|
54
|
-
│ #
|
|
55
|
-
│
|
|
56
|
-
│ #
|
|
57
|
-
│ #
|
|
58
|
-
│ #
|
|
59
|
-
│ #
|
|
60
|
-
│
|
|
61
|
-
│ #
|
|
62
|
-
│ #
|
|
63
|
-
│ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
|
|
64
|
-
│ # (a frozen copy of the history, the prompt, and resumed)
|
|
65
|
-
│ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
|
|
66
|
-
│ # + the plugins' staged tool loads (ctx.load_tools: a kind "tool_change" system entry per bundle,
|
|
67
|
-
│ # only when the turn's host takes tool changes; #append_tool_loads)
|
|
68
|
-
│ # Returns false when a Stop came during the probes, the init wait or the toolkit ask, else true
|
|
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
|
|
69
73
|
│
|
|
70
74
|
├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
|
|
71
75
|
│ # one EffectiveModel (after the hooks, which may switch the model): sync_kernel_client!, then
|
|
72
|
-
│ #
|
|
73
|
-
│ # session's, the model's/host's, else llm_context.strategy)
|
|
74
|
-
│ # (hosts.<name>.models.<id>.price)
|
|
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
|
|
75
81
|
│ # turn.limit = max_iterations or IterationLimit.for
|
|
76
|
-
│ # backend(model).complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input)
|
|
82
|
+
│ # backend(model).complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input),
|
|
83
|
+
│ # context: turn.context)
|
|
77
84
|
│ # Returns LLM::ModelResult (text, conversation, canceled?, exhausted?, tool_activity,
|
|
78
85
|
│ # context_status, empty_steps, empty_retries)
|
|
79
86
|
│
|
|
@@ -85,9 +92,12 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
85
92
|
│ # a continue turn's origin carries its offer turn's, ContinueOffer#run_continue_turn),
|
|
86
93
|
│ # and :turn_completed {result, turn_summary, display_pending} or :turn_canceled
|
|
87
94
|
│ # {cancellation_reason, cancelled_by, duration_ms} is emitted; before the replace,
|
|
88
|
-
│ # stamp_opening puts a Verify turn's `verify` and a completed Revise turn's
|
|
89
|
-
│ # (re-targeted to its stack's top) on the opening message, metadata never sent,
|
|
90
|
-
│ #
|
|
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)
|
|
91
101
|
│ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter,
|
|
92
102
|
│ # refs: the AnswerRefs adder)
|
|
93
103
|
│ # store_answer_display (one replace of the answer: `display` and/or `display_refs`;
|
|
@@ -95,18 +105,41 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
95
105
|
│ # attached refs, or display: nil when the web was told to wait for it)
|
|
96
106
|
│ # then the :session_end hook
|
|
97
107
|
│
|
|
98
|
-
├──
|
|
108
|
+
├── @turn_warmup.after(turn, result)
|
|
99
109
|
│ # local llama.cpp on the native loop only: prefills the next turn's prompt while the user
|
|
100
110
|
│ # reads (PromptWarmup; see prompt-caching.md, "The turn-end warm-up"). Never fails the turn.
|
|
101
111
|
│
|
|
102
112
|
└── ensure: release_turn(probe_cancel_before)
|
|
103
|
-
# TurnState#finish! (turn flag off, controller, sink and steers cleared
|
|
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)
|
|
104
115
|
# SteerCut#finished! (its GenerationPhase finished, its WaitingSteer cleared)
|
|
105
116
|
# probe cancel swapped back
|
|
106
117
|
# the context window cache dropped, so the next turn asks the server again
|
|
107
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
|
|
108
121
|
```
|
|
109
122
|
|
|
123
|
+
### Turn stages
|
|
124
|
+
|
|
125
|
+
A step that keeps state across the turn (its preparation, its end) is a collaborator under `lib/samagotchi/turn_stages/`, built
|
|
126
|
+
in `Engine#initialize` (before the plugins load) from constructor arguments, never reading the Engine's ivars, and
|
|
127
|
+
called by name from the skeleton above, next to the STOP CHECKs. Their input is the `Turn` (and its `TurnContext`);
|
|
128
|
+
their state is their own. The Engine's public methods forward to them (`warm_toolkit_suggestion!`,
|
|
129
|
+
`announce_objectives`, `propose_objective`, the private `judge_late_candidates`).
|
|
130
|
+
|
|
131
|
+
| Stage | Built with | Called | State |
|
|
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 |
|
|
138
|
+
|
|
139
|
+
On a shared local System 1 host the judge's asks wait for the turn's end: started before `TurnStages::Warmup#after`, whose
|
|
140
|
+
warm-up waits for them, or from `run_turn`'s ensure (docs/context.md, The judge). See configuration.md (Toolkits) and
|
|
141
|
+
guardrails.md (System 1) for the suggestion.
|
|
142
|
+
|
|
110
143
|
### Error paths
|
|
111
144
|
|
|
112
145
|
```
|
|
@@ -252,9 +285,10 @@ ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controlle
|
|
|
252
285
|
|
|
253
286
|
The differences from the native loop:
|
|
254
287
|
|
|
255
|
-
- The tools go in each request's `tools` field (`tool_definitions(conversation, model:)
|
|
288
|
+
- The tools go in each request's `tools` field (`tool_definitions(conversation, model:)`, the turn's
|
|
289
|
+
`ToolSurface#schemas`), not in the system prompt.
|
|
256
290
|
On an adapter that loads tools (`#loads_tools?`: `AnthropicMessages`), the tools the session reaches but doesn't
|
|
257
|
-
declare follow, `defer_loading: true` (`
|
|
291
|
+
declare follow, `defer_loading: true` (`ToolSurface#deferred_for`; anthropic.md, "Deferred tools"); the Run keeps the
|
|
258
292
|
last request's deferred names and logs a call of one (`tool_direct_deferred`).
|
|
259
293
|
- The conversation is converted to the OpenAI wire format (`wire_messages`), for every chat host;
|
|
260
294
|
`AnthropicMessages` translates that again into Messages' `system` and content blocks (`LLM::AnthropicWire`).
|
|
@@ -298,25 +332,26 @@ Both loops run a batch of calls the same way:
|
|
|
298
332
|
```
|
|
299
333
|
One iteration with tool calls:
|
|
300
334
|
KernelLoop#dispatch_calls / ChatLoop::Run#dispatch
|
|
301
|
-
→ ToolResponse.run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
|
|
302
|
-
#
|
|
335
|
+
→ ToolResponse.run_batch(runner, calls, context:, iteration:, emit:, on_stream_event:, cap:)
|
|
336
|
+
# context: the turn's TurnContext (the native run's, ChatLoop::Run's); chat passes a block,
|
|
337
|
+
# called with each run as it finishes
|
|
303
338
|
→ :tool_dispatch_started {iteration, call_count}
|
|
304
|
-
→ for each call: ToolRunner#run(call, iteration:, call_index:, call_count:,
|
|
339
|
+
→ for each call: ToolRunner#run(call, context:, iteration:, call_index:, call_count:,
|
|
305
340
|
on_stream_event:, max_tool_output_chars:)
|
|
306
341
|
→ under TurnSettings#loads_tools, a direct call of a source tool's acts_as (mcp_fs_read) is wrapped
|
|
307
342
|
into the call_tool call it stands for (Registry::Index#wrap, ToolIndex#wrap)
|
|
308
343
|
→ first, the call's entry unwraps it (Registry::Entry#unwrap: a call_tool of chi/<name>, a built-in or
|
|
309
344
|
a plugin tool, becomes that tool's call with via: "call_tool", ToolIndex#unwrap); the rest sees the
|
|
310
345
|
real call
|
|
311
|
-
→ a tool the session's toolkit leaves out (
|
|
312
|
-
|
|
313
|
-
reachable tool, the not-loaded error for a removed one). Under
|
|
346
|
+
→ a tool the session's toolkit leaves out (ToolSurface#left_out; with via:, one the surface's index_only
|
|
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
|
|
314
349
|
built-in or plugin tool called by its own name is no such call: it runs through the gate, and KernelLoop#dispatch
|
|
315
|
-
takes it (#
|
|
316
|
-
→ Guardrails::Gate#evaluate (the before_tool_call hooks, then the core checks)
|
|
350
|
+
takes it (ToolSurface#entry_for)
|
|
351
|
+
→ Guardrails::Gate#evaluate(call, context:) (the before_tool_call hooks, then the core checks)
|
|
317
352
|
→ :tool_call_started
|
|
318
|
-
→ an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
|
|
319
|
-
→ KernelLoop#dispatch_tool_call, or the denial text
|
|
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
|
|
320
355
|
→ :tool_call_completed, then the :after_tool_call hook
|
|
321
356
|
→ returns a ToolRun value object with output, capped_output, truncated, activity, images, shown_params,
|
|
322
357
|
shown_label, refs, diff and output_file; fields a call doesn't produce are nil
|
|
@@ -328,7 +363,9 @@ One iteration with tool calls:
|
|
|
328
363
|
(one per call of the batch for native, one for chat); LLM context edits name outputs by them
|
|
329
364
|
```
|
|
330
365
|
|
|
331
|
-
**ToolRunner** (`ToolRunner#run`):
|
|
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).
|
|
332
369
|
- Unwraps a call_tool of a built-in first (`Registry::Entry#unwrap`), so `original`, the params and the label
|
|
333
370
|
are the built-in's and no "ran as:" line follows.
|
|
334
371
|
- Runs the gate first, so `:tool_call_started` shows the call that will run (a before_tool_call hook may
|
|
@@ -344,15 +381,16 @@ One iteration with tool calls:
|
|
|
344
381
|
`refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `ToolSpec#per_call`), run
|
|
345
382
|
once per call by `RowRefs.for` (never raises: a raise or a non-list gives none, `tool_refs_failed`; each entry a
|
|
346
383
|
`Ref`, a bad one dropped, `tool_refs_rejected`; a relative file resolved against the session's folder
|
|
347
|
-
(`
|
|
384
|
+
(`TurnContext#cwd`, else the process's), one outside it (its spelling or its real path) dropped, a kept one
|
|
348
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
|
|
349
386
|
as `refs:`, saved with the result as `tool_refs` (`ToolResponse::SAVED_KEYS`, never sent), and
|
|
350
387
|
`MessageParts#tool_part` reads it back through `RowRefs.for_display` (checked again). The page binds them to the
|
|
351
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
|
|
352
389
|
menu.
|
|
353
390
|
- Settles an ask verdict after `:tool_call_started`, so a UI shows the tool line and then the approval.
|
|
354
|
-
- Dispatches through `KernelLoop#dispatch_tool_call`, or writes the denial text.
|
|
355
|
-
`
|
|
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).
|
|
356
394
|
- For edit and write: diffs the file before and after, and refreshes a memory's index line
|
|
357
395
|
(`MemoryBundle::IndexSync`).
|
|
358
396
|
- Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
|
|
@@ -360,7 +398,7 @@ One iteration with tool calls:
|
|
|
360
398
|
policy: `read` and most tools are cut to their head and end with `[cut: N of M chars; read it in parts]` (N
|
|
361
399
|
kept of M, the whole within the cap unless the cap is shorter than that line); an `execute` result keeps a head
|
|
362
400
|
and a long tail with a gap line between them, its whole output saved in `sessions/<id>/tool_output/`
|
|
363
|
-
(`ToolOutputFiles`,
|
|
401
|
+
(`ToolOutputFiles`, in the turn's `TurnContext#session_dir`). See
|
|
364
402
|
[Tool output guardrails](tool-output-limits.md#the-runners-cut-max_tool_output_chars). The model (both loops), the
|
|
365
403
|
event and the after_tool_call hook get that text.
|
|
366
404
|
- Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, output_file,
|
|
@@ -384,6 +422,31 @@ One iteration with tool calls:
|
|
|
384
422
|
A denied call gets denial text in place of its output; the rest of the batch still runs. After a hook's
|
|
385
423
|
`stop_turn`, the gate denies every remaining call of the batch.
|
|
386
424
|
|
|
425
|
+
**ToolSurface** (`lib/samagotchi/tools/tool_surface.rb`, `Tools::ToolSurface`): the turn's tools as one frozen
|
|
426
|
+
value, built once in `Engine#generate` (`ToolSurface.for(registry, layers:, loads_tools:)`, the settings' LLM context
|
|
427
|
+
layers and the host's `loads_tools` known there) into `TurnContext#surface`. The Registry is its only input (its
|
|
428
|
+
lists `entries`, `reachable_names`, `left_out_names`, `removed_names`, `stranded_names`, read under the Registry's
|
|
429
|
+
toolkit or a `toolkit:` given). It holds `declared` (the entries the request declares, in prompt order), `index_only`
|
|
430
|
+
(left out, reached by `call_tool`), `left_out` (`{name => :indexed | :user_only}`), `removed`, `stranded`,
|
|
431
|
+
`registered`, `toolkit`, `reaching_kit`, `index`, `layers` and `loads_tools`, and answers `entry_for(call)` (a `via:` call only from `index_only`; a direct one from
|
|
432
|
+
`declared`, or `index_only` under `loads_tools`), `unknown_tool_error(name, via:)`, `schemas` and
|
|
433
|
+
`deferred_for(conversation)` (`Tools::DeferredTools.list` over the surface; the index's source tools are asked per
|
|
434
|
+
request, so a late MCP server joins mid-turn as before). Its readers: `KernelLoop#dispatch`,
|
|
435
|
+
`ChatLoop#tool_definitions` / `#deferred_tools` (whether to append the deferred ones is still the adapter's
|
|
436
|
+
`#loads_tools?`, asked per request: a refusal turns it off mid-turn), `ToolRunner`'s unwrap, wrap, left-out and
|
|
437
|
+
group-refusal checks, and the tool index: `find_tools`' and `call_tool`'s handlers take the surface from their
|
|
438
|
+
`KernelLoop::ToolContext#surface`, and the call tool's unwrap and the index's `deferrable` / `wrap` / `unreached`
|
|
439
|
+
are given the runner's or the deferred list's (its `chi` group is the surface's `index_only`). A change while the turn
|
|
440
|
+
runs (`/toolkit`, a staged `chi.replace_tools`, a direct `Registry#register`) shapes the next turn's declared,
|
|
441
|
+
searched and dispatched tools only. A context without one (a bare kernel or loop, a spec) computes one on read
|
|
442
|
+
(`ToolSurface.of(context, registry)`); nothing caches or invalidates it.
|
|
443
|
+
|
|
444
|
+
Readers outside a turn compute one on read (`ToolSurface.for`). On the turn thread, from the Registry: the system
|
|
445
|
+
prompt, once per build before `generate` knows `loads_tools` (`SystemPrompt::Built#surface`; engine_tool_surface_spec
|
|
446
|
+
checks it), `#warn_stranded`, the toolkit suggestion's logged lists (under full), the tool index's `#declare`,
|
|
447
|
+
`#refresh!`, `call_targets` and `call_preview`. Off it, from `ToolkitBinding#published` (below): `#explained`
|
|
448
|
+
(`/toolkit`, the snapshot, the plugin host's `declared_tools`) and `ctx.load_tools`' `#deferred_name`.
|
|
449
|
+
|
|
387
450
|
## State management
|
|
388
451
|
|
|
389
452
|
### TurnState (cross-thread)
|
|
@@ -394,6 +457,8 @@ read: the idle scheduler, the Bridge's request threads, plugin threads, the UIs.
|
|
|
394
457
|
```
|
|
395
458
|
TurnState
|
|
396
459
|
├── running?
|
|
460
|
+
├── context (the running turn's TurnContext; nil between turns)
|
|
461
|
+
├── last_context (the last finished turn's, kept while the next one runs)
|
|
397
462
|
├── controller (the running turn's CancellationController)
|
|
398
463
|
├── thread, ended (the turn's own thread; its end event is out: post-turn work)
|
|
399
464
|
├── sink (the turn's on_event)
|
|
@@ -402,20 +467,38 @@ TurnState
|
|
|
402
467
|
└── activity clock (last_activity_at, activity_seq)
|
|
403
468
|
```
|
|
404
469
|
|
|
405
|
-
- `begin!(controller:, sink:)`: running, with controller and sink (and the calling
|
|
406
|
-
step; carried steers become steers.
|
|
470
|
+
- `begin!(context:, controller:, sink:)`: running, with the turn's context, controller and sink (and the calling
|
|
471
|
+
thread, the turn's own), in one step; carried steers become steers. `Engine#begin_turn` builds the context whole
|
|
472
|
+
before it (GuardrailWiring#begin_turn's mode and git included), so no reader sees a running turn with the last
|
|
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.
|
|
407
476
|
- `ended!`: the turn's end is decided (`end_turn`, next to `turn.ended`): its after_turn / session_end hooks run
|
|
408
477
|
after it and before `finish!`.
|
|
409
478
|
- `own_controller(handler:)`: the controller for work done for the turn itself, which a plugin's `ctx.http` call
|
|
410
479
|
without `cancel:` stops with (`Plugin::Host#cancel_controller`, `Engine#plugin_cancel_controller`): the turn's, on
|
|
411
480
|
its thread or inside a plugin handler, until `ended!`; nil elsewhere (an anytime command, a service, a plugin's
|
|
412
481
|
own thread) and outside a turn. An init task's own controller wins inside one.
|
|
413
|
-
- `finish!`: not running, controller, thread and sink cleared
|
|
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).
|
|
414
484
|
- `steer(text, source:)`: queues a steer for the running turn (false with no turn).
|
|
415
485
|
- `steer_next_turn(text, source:)`: queues a steer for the next turn that begins.
|
|
416
486
|
|
|
417
487
|
The lock is a leaf: nothing is called out while it is held.
|
|
418
488
|
|
|
489
|
+
**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` (`TurnState#context`, nil
|
|
491
|
+
between turns) and `Engine#last_turn_context`, one read each, never through the kernel, `GuardrailWiring` or an
|
|
492
|
+
Engine ivar. The readers that do: `Engine#turn_author` (`ctx.turn_author`, the hooks' `author:`, the judgments'
|
|
493
|
+
`participant:`, `ObjectivesCheck`), `#side_author` (the recap's: the last turn's), `EventFanout#working_set_llm_context`
|
|
494
|
+
(`/working-set`'s list: the running turn's settings, the last turn's until it has its own) and
|
|
495
|
+
`#live_conversation` (nil unless the loop's `LLMContextForget::Turn#turn_id` is the running turn's). The turn's own
|
|
496
|
+
code takes the context as an argument (the tool path, `guard_participant_spend`, `TurnStages::Warmup#after`'s
|
|
497
|
+
`turn.context.settings`); cancel stays `TurnState#controller` (a Stop never reads `context.controller` off the
|
|
498
|
+
turn). `spec/turn_context_readers_spec.rb` reads each from a thread the moment a turn is published. The tool
|
|
499
|
+
Registry likewise: off the turn thread it is `ToolkitBinding#published`, a frozen `Registry#snapshot` (its entry set;
|
|
500
|
+
the `Entry` Structs shared) swapped at the Engine's build, after `apply_staged_tools!` and after `apply_toolkit!`.
|
|
501
|
+
|
|
419
502
|
### EffectiveModel (a snapshot per read)
|
|
420
503
|
|
|
421
504
|
`EffectiveModel.of(registry, choice)` resolves the model once (`HostRegistry#resolve`: host, client, the id
|
|
@@ -464,19 +547,47 @@ host can't do, off the model didn't honour, a host refusing the thinking fields
|
|
|
464
547
|
keeps the turn's `[level, ModelTarget]` and whether the generation thought; `turn_settings` and the stream handler
|
|
465
548
|
call it. Turn thread only.
|
|
466
549
|
|
|
467
|
-
### Turn
|
|
550
|
+
### Turn and TurnContext
|
|
468
551
|
|
|
469
|
-
|
|
552
|
+
One source of truth: the `TurnContext` holds what the tool path reads of a turn, and `Engine::Turn` keeps only
|
|
553
|
+
its lifecycle state, reading the rest from its context (`turn.id`, `turn.session`, `turn.origin`,
|
|
554
|
+
`turn.controller` are the context's). The KernelLoop has its own `Turn` struct, a run's.
|
|
470
555
|
|
|
471
556
|
```ruby
|
|
472
|
-
|
|
473
|
-
|
|
557
|
+
TurnContext = Data.define(:id, :session, :origin, :author, :participant_mode, :participant, :controller, :cwd,
|
|
558
|
+
:session_dir, :state_dir, :git, :settings, :model_key, :fallback_model_key, :model_name,
|
|
559
|
+
:surface) # frozen; TurnContext.none for a bare kernel, a spec, the bundle test kit
|
|
560
|
+
Turn = Struct.new(:context, :prompt, :continue, :on_event, :started_at, :messages, :ended, :prepared_settings,
|
|
561
|
+
:limit, :resumed, :resume_from, :wall, :verify, :revises, :outcome) do
|
|
474
562
|
def resumed? = resumed ? true : false # a step-limit Continue
|
|
475
|
-
def tag(event) = origin ? event.merge(origin: origin.to_h)
|
|
563
|
+
def tag(event) = origin.empty? ? event : event.merge(origin: origin.to_h) # boundary events carry the origin
|
|
476
564
|
def elapsed = ... # seconds since started_at
|
|
477
565
|
end
|
|
478
566
|
```
|
|
479
567
|
|
|
568
|
+
- `TurnContext` is built in `begin_turn` (`new_turn_context`) and published by `TurnState#begin!`; `settings` (the
|
|
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).
|
|
586
|
+
- 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 the kernel gets at
|
|
588
|
+
`KernelLoop.new(services:)` or `#attach` (a spec's kernel: `attach_services(kernel, tools: …)`). No turn field
|
|
589
|
+
goes in Services, no Engine-lifetime one in TurnContext.
|
|
590
|
+
|
|
480
591
|
- `messages` is what the Engine sends: system head, history, reminders, the prompt. The loop works on its own
|
|
481
592
|
copy; the Engine keeps `result.conversation` after a normal end or a Stop, and the error's partial
|
|
482
593
|
conversation after a failure.
|
|
@@ -485,9 +596,10 @@ end
|
|
|
485
596
|
- `on_event` is the turn's sink.
|
|
486
597
|
- `ended` is set by `end_turn`.
|
|
487
598
|
- The live conversation: both loops register the array they append to on the kernel
|
|
488
|
-
(`KernelLoop#llm_context_turn`, `LLMContextForget::Turn#conversation
|
|
489
|
-
`ChatLoop::Run#initialize`). `Engine#live_conversation` returns it while a turn runs
|
|
490
|
-
never clears it
|
|
599
|
+
(`KernelLoop#llm_context_turn`, `LLMContextForget::Turn#conversation`, tagged with the run's `turn_id`;
|
|
600
|
+
`KernelLoop#start_turn`, `ChatLoop::Run#initialize`). `Engine#live_conversation` returns it while a turn runs and
|
|
601
|
+
only once its loop registered it (nil otherwise: the kernel never clears it, and the turn id tells the last turn's
|
|
602
|
+
from this one's). A worker's drain (`Worker#pending_input_drain`, on the turn's thread at every iteration
|
|
491
603
|
boundary) copies it, with the lines that boundary merges, into `Session#turn_checkpoint` (`TurnCheckpoint`):
|
|
492
604
|
every save while the turn runs writes that copy with an `interrupted_turn` mark instead of the session's
|
|
493
605
|
messages, written at the first boundary and then at most every 10 s, so a killed worker loses at most that much
|
|
@@ -581,9 +693,10 @@ make theirs, and `Engine#run_turn`, the Worker and `GuardrailWiring` pass it on.
|
|
|
581
693
|
`TurnOrigin#participant_author` does. The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry
|
|
582
694
|
`origin` when the turn was queued with one: `{client_id:, enqueued_id:}`, plus `author:` when someone other than
|
|
583
695
|
the session's owner wrote it (its input file's `author`, stamped by chi web from the request's `Web::Principal`).
|
|
584
|
-
An owner's turn has no `author` anywhere: not in its input file, its origin or the session.
|
|
585
|
-
|
|
586
|
-
(the
|
|
696
|
+
An owner's turn has no `author` anywhere: not in its input file, its origin or the session. The turn's
|
|
697
|
+
`TurnContext` carries the origin and its participant author (`TurnContext#author`) for the gate's checks
|
|
698
|
+
(`GuardrailWiring#begin_turn` decides the participant's mode from it); the guardrails `Context` (the hooks'
|
|
699
|
+
`context`) keeps the wire Hash.
|
|
587
700
|
|
|
588
701
|
Where a participant's author (`"p:<id>"`; `Steer.participant_author?`: any author but none or `"owner"`) goes:
|
|
589
702
|
|
|
@@ -606,13 +719,14 @@ Where a participant's author (`"p:<id>"`; `Steer.participant_author?`: any autho
|
|
|
606
719
|
`Worker#run_prompt` checks their link and cap (`participant_refusal`: `participants.json`'s entry and cap against
|
|
607
720
|
`SessionMetrics#spend`) before their turn starts and after it ends (the owner's notice, once per cap). A Continue
|
|
608
721
|
keeps the offer turn's author (`ContinueOffer#run_continue_turn`, from `last_turn.author`).
|
|
609
|
-
- **The gate**: `GuardrailWiring#participant_boundary` is the core check after the load failures, picked by
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
612
725
|
outside `Toolkit::REVIEW_TOOLS` (not `forget_outputs`), the pair boundary denies secrets, owner-voiced tools and
|
|
613
726
|
`ask_user_question` and always asks before a write that may land outside the project (docs/guardrails.md).
|
|
614
727
|
The declared tools stay the session's (D3: no rebuild per author).
|
|
615
|
-
- **Hooks and plugins**: `Engine#turn_author` (the running turn's participant author, nil otherwise
|
|
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
|
|
616
730
|
runtime's `turn_author` (`Hooks::Registry#with_runtime` adds `author:` to every event of such a turn) and the
|
|
617
731
|
plugin host's (`ctx.turn_author`).
|
|
618
732
|
- **Spend**: the analytics turn record's `author` feeds `SessionMetrics#spend` / `spend_by_author` (the snapshot's
|
|
@@ -655,11 +769,14 @@ Engine initialization (in this order, among other things):
|
|
|
655
769
|
declared per toolkit by Registry#declared? (a source registered at load, or a tool registered then that
|
|
656
770
|
the toolkit leaves reachable: Registry::Index#loaded; Toolkit::Resolved#declares? treats built-ins and
|
|
657
771
|
plugin tools alike: full declares both, another kit its list and adds) and
|
|
658
|
-
sets call_tool's unwrap (Tools::ToolIndex: the chi group from
|
|
772
|
+
sets call_tool's unwrap (Tools::ToolIndex: the chi group from ToolSurface#index_only, the sources'
|
|
659
773
|
groups, search, call checks, the unwrap; a source runs the call with its own plugin Context)
|
|
660
774
|
@reminder_store = ReminderStore.new (the kernel gets it), @reminder_queue, @reminders
|
|
661
|
-
wire_kernel:
|
|
662
|
-
|
|
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=)
|
|
663
780
|
@hooks.runtime = @hook_routes.runtime (HookRoutes, built first: what a hook or plugin does reaches the UIs)
|
|
664
781
|
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
665
782
|
@prompt_builder = SystemPrompt.new(...)
|
|
@@ -672,7 +789,8 @@ Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
|
|
|
672
789
|
one assignment)
|
|
673
790
|
→ drops the given profile and the profile resolution (resolved again on first need; one that another
|
|
674
791
|
thread finishes for the old model is neither kept nor applied: #store_profile)
|
|
675
|
-
→ sync_model_key
|
|
792
|
+
→ sync_model_key! (the next turn's TurnContext takes the keys), @prompt_builder.reset! (the system prompt is
|
|
793
|
+
built again)
|
|
676
794
|
→ sync_kernel_client! (the kernel talks to the new host), the context window cache dropped
|
|
677
795
|
→ the old model's ctx dropped: @metrics.forget_model_reports! (its window and profile reports) and
|
|
678
796
|
@last_context_status = nil; /model (SessionCommands#switch_model) then calls #announce_switched_context!
|
|
@@ -729,7 +847,7 @@ loops). It is display-only and never reaches the wire (prompt-caching.md, rule 7
|
|
|
729
847
|
`MessageTime.gap_line` (10 min, `GAP_SECONDS`).
|
|
730
848
|
|
|
731
849
|
`:question_asked` (`QuestionAsked`, `question_asked.rb`) fires from the two Engine seams where a question of the
|
|
732
|
-
model's opens, never from `QuestionDesk`: `Engine#request_question` (the kernel's `question_handler`, after
|
|
850
|
+
model's opens, never from `QuestionDesk`: `Engine#request_question` (the kernel's `Services#question_handler`, after
|
|
733
851
|
`AskUserQuestion.validate`; not with `interface == :non_interactive`) and the guardrail wiring's `ask:` lambda
|
|
734
852
|
(`GuardrailWiring#request_approval`, after a stored approval and the non-interactive deny). So `ask_for_hook`, a
|
|
735
853
|
relayed approval (`Engine::RelayPeer#open_question` → `Engine#open_question`) and `post` (the step-limit question) never
|
|
@@ -766,7 +884,9 @@ step's text ([architecture.md](../architecture.md), the `display_refs` paragraph
|
|
|
766
884
|
|
|
767
885
|
## Guardrails
|
|
768
886
|
|
|
769
|
-
`ToolRunner` asks the kernel's `Guardrails::Gate` (set by the Engine from `GuardrailWiring`) before each call
|
|
887
|
+
`ToolRunner` asks the kernel's `Guardrails::Gate` (set by the Engine from `GuardrailWiring`) before each call,
|
|
888
|
+
with the call's `TurnContext` (`Gate#evaluate(call, context:)`: its controller, model key and name, and what
|
|
889
|
+
`GuardrailWiring#context(turn)` / `#checks(turn)` make of it; `GuardrailWiring` keeps no per-turn state);
|
|
770
890
|
the gate is not itself a hook. `Gate#evaluate` fires the `:before_tool_call` hooks (the voters, e.g. the
|
|
771
891
|
known-names bundle or a config hook), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
|
|
772
892
|
`event[:blocked]` flag. A deny stays a deny for the hooks after it; a hook's ask is dropped when guardrails are
|
|
@@ -884,18 +1004,19 @@ Paths are under `lib/samagotchi/`.
|
|
|
884
1004
|
| `engine/model_settings.rb` | `Engine::ModelSettingsPart`: the session's model settings (SessionModelSettings) and the snapshots (EngineSnapshots), forwarded |
|
|
885
1005
|
| `engine/events.rb` | `Engine::EventsPart`: observers, `#announce`, the event lock, held announcements, load events, the emit helpers |
|
|
886
1006
|
| `engine/cards.rb` | `Engine::CardsPart`: `#show_card`, anytime commands and their threads |
|
|
887
|
-
| `engine/objectives.rb` | `Engine::ObjectivesPart`: the objectives'
|
|
1007
|
+
| `engine/objectives.rb` | `Engine::ObjectivesPart`: the objectives' stage builder, `announce_objectives` and `propose_objective` (forwarded), Check this's side request |
|
|
888
1008
|
| `engine/messages.rb` | `Engine::MessagesPart`: the session messages API (out-of-turn edits, checkpoints, the live conversation) |
|
|
889
1009
|
| `engine/plugins.rb` | `Engine::PluginsPart`: loading plugins, init tasks and staged tools, `Plugin::Host` |
|
|
890
1010
|
| `engine/hooks.rb` | `Engine::HooksPart`: turn-scoped hooks, the hook runtime (HookRoutes, forwarded), reminders |
|
|
891
1011
|
| `engine/guardrails.rb` | `Engine::GuardrailsPart`: the guardrails' readers and the first turn's load warnings |
|
|
892
1012
|
| `engine/participants.rb` | `Engine::ParticipantsPart`: the turn's author, a pair turn's stop, the participants line |
|
|
893
1013
|
| `engine/session.rb` | `Engine::SessionPart`: `#session=`, the state dir, the relay's view, the system prompt and its memory names |
|
|
894
|
-
| `engine/verify.rb` | `Engine::VerifyPart`: Verify
|
|
1014
|
+
| `engine/verify.rb` | `Engine::VerifyPart`: the Verify/Revise anchors' stage builder |
|
|
895
1015
|
| `engine/questions.rb` | `Engine::QuestionsPart`: ask-user-question (QuestionDesk, forwarded) |
|
|
896
|
-
| `engine/toolkit.rb` | `Engine::ToolkitPart`: the toolkit, `ctx.load_tools`, System 1's toolkit suggestion |
|
|
897
|
-
| `engine/context_judge.rb` | `Engine::ContextJudgePart`: the
|
|
898
|
-
| `
|
|
1016
|
+
| `engine/toolkit.rb` | `Engine::ToolkitPart`: the toolkit, `ctx.load_tools`, System 1's toolkit suggestion (the stage's builder and `warm_toolkit_suggestion!`, forwarded) |
|
|
1017
|
+
| `engine/context_judge.rb` | `Engine::ContextJudgePart`: the candidate judge's stage builder and `judge_late_candidates`, forwarded |
|
|
1018
|
+
| `turn_stages/toolkit_suggestion.rb`, `turn_stages/context_judge.rb`, `turn_stages/objectives.rb`, `turn_stages/warmup.rb`, `turn_stages/verify_anchors.rb` | `TurnStages::ToolkitSuggestion`, `TurnStages::ContextJudge`, `TurnStages::Objectives`, `TurnStages::Warmup`, `TurnStages::VerifyAnchors`: the turn stages ("Turn stages" above) |
|
|
1019
|
+
| `engine/turn_helpers.rb` | `Engine::TurnHelpersPart`: a turn's settings, images, model ref, used memories, the turn-end warm-up's stage builder |
|
|
899
1020
|
| `engine/turn_end.rb` | `Engine::TurnEndPart`: the normal ending and after-turn hooks, release, what an ending keeps, last_turn, the answer's display |
|
|
900
1021
|
| `event_fanout.rb` | `EventFanout`: how an event reaches the turn's sink and the observers (`emit_event`), the loops' stream handler, the working set's emit and dedupe; the Engine forwards |
|
|
901
1022
|
| `engine_snapshots.rb` | `EngineSnapshots`: the UIs' read model, `session_state_snapshot` (cheap, never raises) and `stats_snapshot` (may probe `/props`); the Engine forwards |
|