insika 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -3
  3. data/README.md +1 -1
  4. data/bin/insika +22 -7
  5. data/docs/AGENTS.md +129 -5
  6. data/docs/CHANNELS.md +1 -1
  7. data/docs/CONTEXT.md +22 -5
  8. data/docs/DEPLOY.md +30 -10
  9. data/docs/EMBEDDING.md +11 -7
  10. data/docs/EVALS.md +1 -1
  11. data/docs/LOADTEST.md +3 -2
  12. data/docs/OBSERVABILITY.md +11 -2
  13. data/docs/REFINEMENT.md +6 -6
  14. data/docs/RELEASING.md +7 -7
  15. data/docs/RUNNING-LOCAL.md +1 -1
  16. data/docs/SECURITY.md +24 -11
  17. data/docs/SKILLS.md +189 -3
  18. data/docs/WHY.md +1 -1
  19. data/docs/WORKFLOWS.md +2 -2
  20. data/docs/index.md +1 -1
  21. data/docs/onboarding/start.md +1 -1
  22. data/lib/insika/agent_profile.rb +89 -22
  23. data/lib/insika/alert_dispatcher.rb +139 -0
  24. data/lib/insika/baseline_store.rb +2 -2
  25. data/lib/insika/budget_ledger.rb +135 -0
  26. data/lib/insika/channel_delivery.rb +14 -11
  27. data/lib/insika/channel_registry.rb +1 -1
  28. data/lib/insika/channels/relay.rb +3 -3
  29. data/lib/insika/channels/web/widget.js +2 -2
  30. data/lib/insika/channels/web.rb +7 -7
  31. data/lib/insika/channels/webhook.rb +58 -0
  32. data/lib/insika/chat_builder.rb +62 -13
  33. data/lib/insika/circuit_state.rb +114 -0
  34. data/lib/insika/coercion.rb +8 -0
  35. data/lib/insika/commands/agent_payload.rb +5 -3
  36. data/lib/insika/commands/create_agent.rb +2 -2
  37. data/lib/insika/commands/create_session.rb +1 -1
  38. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  39. data/lib/insika/commands/delete_skill.rb +43 -0
  40. data/lib/insika/commands/gate_refinement.rb +12 -12
  41. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  42. data/lib/insika/commands/import_tools.rb +4 -4
  43. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  44. data/lib/insika/commands/resolve_refinement.rb +1 -1
  45. data/lib/insika/commands/revoke_token.rb +39 -0
  46. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  47. data/lib/insika/commands/run_refinement.rb +5 -5
  48. data/lib/insika/commands/send_message.rb +9 -9
  49. data/lib/insika/commands/set_agent_tools.rb +1 -1
  50. data/lib/insika/commands/set_skill_agents.rb +60 -19
  51. data/lib/insika/commands/trigger_workflow.rb +1 -1
  52. data/lib/insika/commands/update_agent.rb +1 -1
  53. data/lib/insika/commands/write_data_tool.rb +1 -1
  54. data/lib/insika/commands/write_golden.rb +1 -1
  55. data/lib/insika/commands/write_skill.rb +19 -9
  56. data/lib/insika/config_store.rb +8 -4
  57. data/lib/insika/context/builder.rb +2 -2
  58. data/lib/insika/context/fragment.rb +27 -3
  59. data/lib/insika/context/priority.rb +3 -2
  60. data/lib/insika/context/providers/memory.rb +1 -1
  61. data/lib/insika/context/providers/request.rb +1 -1
  62. data/lib/insika/context/providers/session.rb +17 -2
  63. data/lib/insika/context/providers/skill.rb +5 -1
  64. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  65. data/lib/insika/context_trace_store.rb +92 -0
  66. data/lib/insika/delegation_store.rb +2 -2
  67. data/lib/insika/doctor.rb +250 -5
  68. data/lib/insika/dsl/runtime.rb +12 -9
  69. data/lib/insika/dsl/server_boot.rb +4 -3
  70. data/lib/insika/dsl/system.rb +1 -1
  71. data/lib/insika/dsl.rb +72 -15
  72. data/lib/insika/edge_limiter.rb +144 -6
  73. data/lib/insika/egress_guard.rb +3 -3
  74. data/lib/insika/env_schema.rb +13 -10
  75. data/lib/insika/errors.rb +61 -5
  76. data/lib/insika/evals/assertions.rb +12 -12
  77. data/lib/insika/evals/baseline.rb +3 -3
  78. data/lib/insika/evals/golden.rb +8 -8
  79. data/lib/insika/evals/judge.rb +7 -7
  80. data/lib/insika/evals/pairwise.rb +3 -3
  81. data/lib/insika/evals/report.rb +2 -2
  82. data/lib/insika/evals/runner.rb +6 -6
  83. data/lib/insika/evals/transport.rb +2 -2
  84. data/lib/insika/event_stream.rb +23 -5
  85. data/lib/insika/executor.rb +423 -108
  86. data/lib/insika/frontmatter.rb +1 -1
  87. data/lib/insika/golden_store.rb +2 -2
  88. data/lib/insika/http_client.rb +3 -3
  89. data/lib/insika/inbound_log.rb +1 -1
  90. data/lib/insika/llm_configurator.rb +3 -3
  91. data/lib/insika/loop_detector.rb +143 -0
  92. data/lib/insika/mcp_http_client.rb +4 -4
  93. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  94. data/lib/insika/message_origin.rb +2 -2
  95. data/lib/insika/model_resolver.rb +1 -1
  96. data/lib/insika/model_selection.rb +5 -4
  97. data/lib/insika/onboarding.rb +2 -2
  98. data/lib/insika/outbox_store.rb +2 -2
  99. data/lib/insika/overlay_tool_registry.rb +3 -4
  100. data/lib/insika/pack.rb +3 -3
  101. data/lib/insika/pack_importer.rb +17 -15
  102. data/lib/insika/pending_action_store.rb +1 -1
  103. data/lib/insika/plugin/loader.rb +2 -2
  104. data/lib/insika/policy/policy.rb +1 -1
  105. data/lib/insika/profile_source.rb +12 -6
  106. data/lib/insika/provider_error_classifier.rb +160 -0
  107. data/lib/insika/queue_policy.rb +2 -2
  108. data/lib/insika/recovery.rb +47 -6
  109. data/lib/insika/refinement/candidate.rb +4 -4
  110. data/lib/insika/refinement/evidence_collector.rb +6 -6
  111. data/lib/insika/refinement/gate.rb +7 -7
  112. data/lib/insika/refinement/panel.rb +7 -7
  113. data/lib/insika/refinement/proposer.rb +9 -9
  114. data/lib/insika/refinement_store.rb +12 -12
  115. data/lib/insika/reliability.rb +185 -0
  116. data/lib/insika/safety/config.rb +2 -2
  117. data/lib/insika/safety/detectors.rb +5 -5
  118. data/lib/insika/safety/factory.rb +3 -3
  119. data/lib/insika/safety/input_guardrail.rb +19 -4
  120. data/lib/insika/safety/moderator.rb +19 -11
  121. data/lib/insika/safety/output_filter.rb +2 -2
  122. data/lib/insika/safety/output_validator.rb +2 -2
  123. data/lib/insika/safety/safe_responses.rb +1 -1
  124. data/lib/insika/sandbox/boundary.rb +2 -2
  125. data/lib/insika/sandbox.rb +1 -1
  126. data/lib/insika/server/app.rb +223 -51
  127. data/lib/insika/server/boot.rb +4 -4
  128. data/lib/insika/server/rack_app.rb +15 -7
  129. data/lib/insika/server/responses.rb +18 -8
  130. data/lib/insika/server/tenant_auth.rb +61 -0
  131. data/lib/insika/session_actor.rb +3 -3
  132. data/lib/insika/session_store.rb +1 -1
  133. data/lib/insika/settings_store.rb +5 -5
  134. data/lib/insika/shutdown.rb +4 -4
  135. data/lib/insika/skill_catalog.rb +127 -20
  136. data/lib/insika/skill_store.rb +70 -22
  137. data/lib/insika/steer_injector.rb +1 -1
  138. data/lib/insika/store.rb +1 -1
  139. data/lib/insika/studio/app.rb +183 -61
  140. data/lib/insika/studio/assets/dist/application.js +25 -24
  141. data/lib/insika/studio/forms.rb +13 -18
  142. data/lib/insika/studio/nav_icons.rb +1 -1
  143. data/lib/insika/studio/views/_message.erb +2 -2
  144. data/lib/insika/studio/views/agent_detail.erb +2 -2
  145. data/lib/insika/studio/views/agents.erb +1 -1
  146. data/lib/insika/studio/views/refinement.erb +4 -4
  147. data/lib/insika/studio/views/session.erb +78 -3
  148. data/lib/insika/studio/views/settings.erb +7 -12
  149. data/lib/insika/studio/views/skills.erb +67 -12
  150. data/lib/insika/subagent_graph.rb +3 -3
  151. data/lib/insika/task_actor.rb +3 -3
  152. data/lib/insika/task_store.rb +1 -1
  153. data/lib/insika/telemetry/pricing.rb +3 -3
  154. data/lib/insika/telemetry/recorder.rb +1 -1
  155. data/lib/insika/telemetry.rb +2 -2
  156. data/lib/insika/testing/store_contract.rb +27 -27
  157. data/lib/insika/tick.rb +122 -0
  158. data/lib/insika/token_store.rb +168 -0
  159. data/lib/insika/tool_assembly.rb +5 -5
  160. data/lib/insika/tool_definition.rb +8 -8
  161. data/lib/insika/tool_envelope.rb +1 -1
  162. data/lib/insika/tool_manifest.rb +6 -6
  163. data/lib/insika/tool_output_compressor.rb +100 -0
  164. data/lib/insika/tool_store.rb +1 -1
  165. data/lib/insika/tool_trace_store.rb +1 -1
  166. data/lib/insika/tools/concurrency.rb +2 -2
  167. data/lib/insika/tools/data_defined_tool.rb +4 -5
  168. data/lib/insika/tools/load_skill.rb +61 -3
  169. data/lib/insika/tools/stuck_signal.rb +44 -0
  170. data/lib/insika/tools/subagent.rb +4 -4
  171. data/lib/insika/tools/subagents.rb +1 -1
  172. data/lib/insika/turn_output.rb +2 -2
  173. data/lib/insika/turn_state.rb +17 -13
  174. data/lib/insika/turn_timing.rb +2 -2
  175. data/lib/insika/usage_ledger.rb +1 -1
  176. data/lib/insika/version.rb +1 -1
  177. data/lib/insika/wiring/graph.rb +77 -26
  178. data/lib/insika/workflow.rb +1 -1
  179. data/lib/insika/workflow_registry.rb +1 -1
  180. data/lib/insika.rb +32 -15
  181. metadata +19 -2
  182. data/lib/insika/server/admin_auth.rb +0 -29
@@ -19,7 +19,8 @@ module Insika
19
19
  event_stream:, workflow_registry: nil, pending_action_store: nil,
20
20
  capability_registry: nil, tool_catalog: nil, memory_store: nil,
21
21
  tool_trace_store: nil, settings_store: nil, content_filter_factory: nil,
22
- delegation_store: nil, channel_delivery: nil, llm: nil)
22
+ delegation_store: nil, channel_delivery: nil, llm: nil,
23
+ context_trace_store: nil, reliability: nil)
23
24
  @context_builder = context_builder
24
25
  @policy_engine = policy_engine
25
26
  @middleware = middleware
@@ -36,31 +37,38 @@ module Insika
36
37
  @pending_action_store = pending_action_store # approval gate
37
38
  @capability_registry = capability_registry # capability resolution (nil = off)
38
39
  @tool_trace_store = tool_trace_store # tool-call trace for Studio debugging (nil = off)
39
- # Guardrails output filter (RFC-0009 §3.2): ->(state) { OutputFilter | nil }.
40
+ # per-turn context breakdown (tokens by category + budget) for the
41
+ # Studio session card. nil = off (no record, zero overhead — parity).
42
+ @context_trace_store = context_trace_store
43
+ # Guardrails output filter: ->(state) { OutputFilter | nil }.
40
44
  # Injected by the Safety::Factory; nil = off (parity — the stream is untouched).
41
45
  # The INPUT guardrail is a Middleware (in the stack, not here); this is the seam
42
46
  # for the stream-side redaction the Executor owns.
43
47
  @content_filter_factory = content_filter_factory
44
- # RFC-0010 Fase 2: durable record of ASYNC delegations. nil = async
48
+ # durable record of ASYNC delegations. nil = async
45
49
  # delegation OFF (only the synchronous spawn_subagent works — parity). When
46
50
  # present, run_subagent(async: true) dispatches + returns immediately and the
47
51
  # child's result is delivered to the parent session as a NEW turn on completion.
48
52
  @delegation_store = delegation_store
49
- # RFC-0011 §6.5: outbound delivery for Shape B channels. nil = no channel
53
+ # outbound delivery for Shape B channels. nil = no channel
50
54
  # delivers out of band (parity — every surface today answers on the request's
51
55
  # own connection). When present, a turn that CAME IN through a channel writes
52
56
  # its answer to the outbox at the terminal and the dispatcher POSTs it.
53
57
  @channel_delivery = channel_delivery
54
- # RFC-0017 A2: the chat FACTORY this executor asks — a RubyLLM::Context (an
58
+ # the chat FACTORY this executor asks — a RubyLLM::Context (an
55
59
  # isolated config dup) when the graph owns its credentials, nil = the
56
60
  # process-wide RubyLLM constant (the historic single-graph deployment).
57
61
  # Duck-typed: Context#chat and RubyLLM.chat take the same keywords.
58
62
  @llm = llm
59
- # LLM config v2 (§10): resolves the model at turn start (Chat > Agent >
63
+ # stability for the turn's single agent interaction (WS3): retries /
64
+ # fallback / circuit breaker, all DATA on AgentProfile#reliability.
65
+ # nil = the plain single ask (parity).
66
+ @reliability = reliability
67
+ # LLM config v2: resolves the model at turn start (Chat > Agent >
60
68
  # platform default) + model_policy + fallback chain. settings_store nil =
61
69
  # no platform layer (pre-v2 behavior: the agent's own model is used as-is).
62
70
  @model_resolver = ModelResolver.new(settings_store: settings_store)
63
- # RFC-0015 §4: the platform layer of the queue policy (nil = per-agent and
71
+ # the platform layer of the queue policy (nil = per-agent and
64
72
  # defaults only, which is `followup` with no window — today's behavior).
65
73
  @settings_store = settings_store
66
74
  # RubyLLM glue (stages 5-7): chat assembly delegated to ChatBuilder. Its
@@ -71,13 +79,15 @@ module Insika
71
79
  tool_registry: tool_registry, skill_catalog: skill_catalog,
72
80
  checkpoint_store: checkpoint_store, event_stream: event_stream, hooks: hooks,
73
81
  tool_catalog: tool_catalog, memory_store: memory_store,
74
- # RFC-0010: the ChatBuilder wires the spawn_subagent system tool (gated by
82
+ # load_skill is not enveloped, so it records its own trace entry.
83
+ tool_trace_store: tool_trace_store,
84
+ # the ChatBuilder wires the spawn_subagent system tool (gated by
75
85
  # profile.subagents) and hands it this Executor as the runner. `self` is not
76
86
  # yet fully built here, but the ChatBuilder only STORES it (used per-turn).
77
87
  subagent_runner: self
78
88
  )
79
- # Stage-3-tail tool assembly (capability resolution, instantiation, D2
80
- # injection, dedup join, ToolEnvelope wrap) — extracted collaborator (§11 B5).
89
+ # Stage-3-tail tool assembly (capability resolution, instantiation,
90
+ # injection, dedup join, ToolEnvelope wrap) — extracted collaborator.
81
91
  @tool_assembly = ToolAssembly.new(
82
92
  tool_registry: tool_registry, capability_registry: capability_registry,
83
93
  event_stream: event_stream, checkpoint_store: checkpoint_store,
@@ -88,7 +98,7 @@ module Insika
88
98
  @supervised = false # serving mode? — see #turn_parent
89
99
  @supervisor = nil # lazy long-lived supervisor (created when serving)
90
100
  @session_actors = {} # session_id => SessionActor (FIFO queue)
91
- @draining = false # shutdown drain (RFC-0016 A3) — see #begin_drain!
101
+ @draining = false # shutdown drain — see #begin_drain!
92
102
  end
93
103
 
94
104
  # Turns on SERVING mode: the composition root's serving arm (serve.rb /
@@ -102,7 +112,19 @@ module Insika
102
112
  # concurrency).
103
113
  attr_accessor :supervised
104
114
 
105
- # RFC-0016 A3: closes the TURN intake for shutdown. Armed by Insika::Shutdown
115
+ # the periodic tick (outbox drain + stale recovery sweep), wired
116
+ # by the graph AFTER the bus exists (the tick's recovery half dispatches
117
+ # through it). nil = no tick (parity — recovery stays boot-only). When
118
+ # present and serving, it starts as a child of the turn supervisor (see
119
+ # #turn_parent).
120
+ attr_accessor :tick
121
+
122
+ # The WS6 alert dispatcher: answers budget_warning / breaker_open /
123
+ # delivery_failed with a durable webhook delivery. Started as a child of
124
+ # the turn supervisor in serving mode (like the tick); nil = no alerts.
125
+ attr_accessor :alert_dispatcher
126
+
127
+ # closes the TURN intake for shutdown. Armed by Insika::Shutdown
106
128
  # when the process is asked to stop: from here on a new top-level turn is left
107
129
  # `:queued` (durable — the next boot's recovery replays it) instead of
108
130
  # spawning, while the in-flight turns run to their natural end. One-way by
@@ -217,7 +239,7 @@ module Insika
217
239
  # time); without a session_id (one-shot/history) it goes straight to spawn
218
240
  # (standalone).
219
241
  def spawn_in_session(task, profile:, resume_from: nil)
220
- # RFC-0016 A3: the intake is closed. The task is already durable (:queued);
242
+ # the intake is closed. The task is already durable (queued);
221
243
  # answering with its id and spawning NOTHING is what "stops accepting new
222
244
  # turns" means — the next boot's recovery replays it. Subagent turns are NOT
223
245
  # gated (they spawn directly): a child of an in-flight parent is part of the
@@ -237,7 +259,7 @@ module Insika
237
259
  policy: queue_policy(profile, task.session_id))
238
260
  end
239
261
 
240
- # RFC-0015 §5.3 — the `collect` door, asked BEFORE a task is created.
262
+ # the `collect` door, asked BEFORE a task is created.
241
263
  # -> the task id the fragment joined, or nil (create a task and spawn as usual).
242
264
  #
243
265
  # Asking first is what keeps the store clean: creating a task and then
@@ -255,7 +277,7 @@ module Insika
255
277
  actor.collect(text)
256
278
  end
257
279
 
258
- # RFC-0015 §5.1 — the `steer` door: a message for a session whose turn is ALREADY
280
+ # the `steer` door: a message for a session whose turn is ALREADY
259
281
  # running is appended to that run instead of becoming a turn of its own.
260
282
  # -> the RUNNING task's id (the turn that will answer it), or nil (create a task and
261
283
  # spawn as usual, which is `followup`).
@@ -293,7 +315,7 @@ module Insika
293
315
  task.id
294
316
  end
295
317
 
296
- # RFC-0015 §6.4 — the `interrupt` door: the turn in flight is answering a question
318
+ # the `interrupt` door: the turn in flight is answering a question
297
319
  # the customer has already replaced, so it is abandoned and the new message becomes an
298
320
  # ordinary turn. -> the abandoned task's id, or nil (nothing was running).
299
321
  #
@@ -305,7 +327,7 @@ module Insika
305
327
  # `:cancel` is observed only at a stage boundary, so a tool call in flight runs to
306
328
  # completion and is recorded. Cancelling the not-yet-started calls of a batch would
307
329
  # leave it half applied, and fabricating failure results would teach the model that
308
- # tools failed when they did not (D7 records the same boundary for `turn_timeout`).
330
+ # tools failed when they did not (records the same boundary for `turn_timeout`).
309
331
  def interrupt_running(session_id, profile:, replaced_by: nil)
310
332
  return nil unless @supervised && session_id
311
333
 
@@ -329,7 +351,7 @@ module Insika
329
351
  # its own, by design — it owns scheduling, not persistence).
330
352
  attr_reader :task_store
331
353
 
332
- # RFC-0015 §8. Emitted by the SessionActor when a window closes having merged
354
+ # Emitted by the SessionActor when a window closes having merged
333
355
  # more than one fragment. `arrivals` are the ISO8601 times each fragment landed
334
356
  # — the ONLY record that they were separate messages, since a merged fragment
335
357
  # creates no task of its own. Ids and times, never content.
@@ -337,7 +359,7 @@ module Insika
337
359
  emit(:turn_coalesced, { task_id: task.id, merged: merged, arrivals: arrivals }, task: task)
338
360
  end
339
361
 
340
- # RFC-0015 §5.2 — a steered message the run could NOT absorb: no tool batch ever
362
+ # a steered message the run could NOT absorb: no tool batch ever
341
363
  # closed (a text-only turn), the batch ended in `halt_when`, the turn failed, or it
342
364
  # was cancelled. The message is a person's and must not evaporate, so it is released
343
365
  # as the next turn on this session — which is `followup`, arrived at late.
@@ -383,7 +405,7 @@ module Insika
383
405
  # turn error is already mapped to a terminal state inside its own fiber (single
384
406
  # capture); here we only ensure the session loop does not die.
385
407
  def run_serial(task, profile:, resume_from: nil)
386
- # RFC-0016 A3: a drain that started with turns already queued behind this
408
+ # a drain that started with turns already queued behind this
387
409
  # session's current one must not keep feeding the loop — without this gate
388
410
  # the drain would only converge when the whole backlog ran out.
389
411
  return defer_turn(task) if @draining
@@ -408,15 +430,15 @@ module Insika
408
430
  nil
409
431
  end
410
432
 
411
- # RFC-0010 (item 21): runs a CHILD agent turn (called by Tools::Subagent during
433
+ # runs a CHILD agent turn (called by Tools::Subagent during
412
434
  # stage 6). Isolated context (fresh child session), capability NON-inheritance
413
435
  # (child profile resolved fresh), environment inheritance (model/thinking seeded
414
436
  # from the parent). NEVER raises: a bad agent/depth/child failure is a message
415
437
  # to the model, not a turn-killer.
416
438
  #
417
- # async:false (default, Fase 1) — SYNCHRONOUS: runs the child inside the parent's
439
+ # async:false (default) — SYNCHRONOUS: runs the child inside the parent's
418
440
  # fiber and returns { text:, session_id: } (the child result is the tool result).
419
- # async:true (Fase 2) — DURABLE dispatch: spawns the child NON-blocking, persists
441
+ # async:true — DURABLE dispatch: spawns the child NON-blocking, persists
420
442
  # a Delegation, returns { dispatched:, agent:, session_id: } immediately; the
421
443
  # parent turn ends and the child's result is later delivered as a NEW turn on
422
444
  # the parent session (needs a delegation_store — else falls back to sync).
@@ -431,7 +453,7 @@ module Insika
431
453
  end
432
454
  end
433
455
 
434
- # RFC-0010 §A (fan-out): runs SEVERAL child turns IN PARALLEL and returns all
456
+ # (fan-out): runs SEVERAL child turns IN PARALLEL and returns all
435
457
  # results together, in the requested order. This is the real latency win — the
436
458
  # children overlap their provider waits on the reactor, so wall-clock ≈ the
437
459
  # slowest child, not the sum. Always sync-join (a combined result in the parent's
@@ -449,7 +471,7 @@ module Insika
449
471
  { results: spawn_all_and_project(plans, parent_state) }
450
472
  end
451
473
 
452
- # RFC-0010 Fase 2 (boot): reconciles ASYNC delegations after a crash so a
474
+ # (boot): reconciles ASYNC delegations after a crash so a
453
475
  # completed child's result is never lost. For each undelivered Delegation:
454
476
  # · child TERMINAL, not captured -> capture + deliver.
455
477
  # · child TERMINAL, captured (completed) -> deliver (crash before delivery).
@@ -471,7 +493,7 @@ module Insika
471
493
  { delivered: delivered }
472
494
  end
473
495
 
474
- # RFC-0011 §6.5 (boot): re-drives the outbound replies a previous process
496
+ # (boot): re-drives the outbound replies a previous process
475
497
  # recorded and never claimed. Records left `delivering` are NOT swept — that
476
498
  # process may have POSTed before it died, and re-sending is the duplicate the
477
499
  # claim exists to prevent. No-op without a channel_delivery.
@@ -510,8 +532,17 @@ module Insika
510
532
  rescue PolicyDenied => e
511
533
  emit(:policy_denied, { policy: e.policy, reason: e.reason }, task: task)
512
534
  fail_task(task, e, stage: :policy)
535
+ rescue BudgetExceeded => e
536
+ # WS2 hard budget: a typed, retryable failure — the envelope reads
537
+ # budget_exceeded + retry_after (window roll), never a silent drop.
538
+ fail_task(task, e, stage: :budget)
539
+ rescue CircuitOpenError => e
540
+ # WS3 breaker: the turn died BEFORE the provider call — the envelope
541
+ # reads circuit_open + retry_after (cooldown remaining). Worth its own
542
+ # stage: an open breaker is a reliability decision, not an error bug.
543
+ fail_task(task, e, stage: :reliability)
513
544
  rescue Insika::WorkflowSchemaError => e
514
- # Item 22 / §4.4: a workflow OUTPUT that violates its output_schema. Distinct
545
+ # a workflow OUTPUT that violates its output_schema. Distinct
515
546
  # stage so a contract breach is not conflated with an :unknown failure. (INPUT
516
547
  # is validated synchronously in TriggerWorkflow -> 422, never reaches here.)
517
548
  fail_task(task, e, stage: :workflow_schema)
@@ -526,7 +557,15 @@ module Insika
526
557
  rescue TimeoutError => e
527
558
  fail_task(task, e, stage: e.stage)
528
559
  rescue StandardError => e
529
- fail_task(task, e, stage: :unknown)
560
+ # A provider/transport failure is NOT an :unknown bug: wrap it with its
561
+ # action classification (B9) so the envelope can quote retryable and the
562
+ # provider's own retry_after (A8). The classifier is class-name based —
563
+ # the :ruby_llm stage stays reachable even under the smoke-shim's fake.
564
+ if ProviderErrorClassifier.provider_error?(e)
565
+ fail_task(task, ProviderErrorClassifier.wrap(e), stage: :ruby_llm)
566
+ else
567
+ fail_task(task, e, stage: :unknown)
568
+ end
530
569
  ensure
531
570
  @running.delete(task.id) # ALWAYS deregister (a false-positive running? would break the resume)
532
571
  # Deregistered FIRST on purpose: from here on the `steer` door finds no actor for
@@ -537,7 +576,7 @@ module Insika
537
576
 
538
577
  private
539
578
 
540
- # RFC-0015 §4 — resolves session vars > profile.limits > settings["queue"] >
579
+ # resolves session vars > profile.limits > settings["queue"] >
541
580
  # defaults. One session read plus one settings read per message, the same
542
581
  # order of cost the EdgeLimiter already pays per turn. A missing session (or
543
582
  # no session store) simply drops the vars layer.
@@ -558,7 +597,7 @@ module Insika
558
597
  SessionActor.new(session_id: session_id, executor: self, parent: turn_parent)
559
598
  end
560
599
 
561
- # RFC-0016 A3 — a turn that arrived while the process is draining: left
600
+ # a turn that arrived while the process is draining: left
562
601
  # `:queued` on purpose (recovery replays :queued at the next boot). The event
563
602
  # is the deferral's only trace — without it, "why was my message answered
564
603
  # only after the deploy" is unanswerable.
@@ -597,9 +636,17 @@ module Insika
597
636
  # 2.42): blocks on a dequeue that never arrives. Ends only when the scope is
598
637
  # stopped (server shutdown) — then any child turns still running go with it.
599
638
  # In a deployment that is the LAST resort, not the plan: Insika::Shutdown
600
- # drains first (RFC-0016 A3), so only what outlives the drain deadline dies
639
+ # drains first, so only what outlives the drain deadline dies
601
640
  # here, `:running`, for the next boot's recovery to replay.
602
641
  @supervisor = node.async { |t| t.annotate("harness-turn-supervisor"); Async::Queue.new.dequeue }
642
+ # the periodic tick is a child of the supervisor — it binds to
643
+ # the serving reactor in every arm with no arm edits, and dies with the
644
+ # supervisor at shutdown (after Shutdown's drain, like any turn).
645
+ @tick&.start(parent: @supervisor)
646
+ # the alert dispatcher (WS6) lives on the same supervisor: its consumer
647
+ # answers every alert event for as long as the process serves.
648
+ @alert_dispatcher&.start(parent: @supervisor)
649
+ @supervisor
603
650
  end
604
651
 
605
652
  # Deterministic PendingAction id: correlation by task+turn+tool.
@@ -608,7 +655,7 @@ module Insika
608
655
  # checkpointing is a future slice. One call per tool is safe.
609
656
  def pending_id(task_id, turn, tool) = "#{task_id}:#{turn}:#{tool}"
610
657
 
611
- # §11 B2: all readings of the persisted command go through rebuild_command
658
+ # all readings of the persisted command go through rebuild_command
612
659
  # (the single normalizer). command_type stays a STRING (the :task_started
613
660
  # event and the Telemetry attribute are string-typed).
614
661
  def command_type(task)
@@ -641,10 +688,17 @@ module Insika
641
688
  return nil
642
689
  end
643
690
 
644
- @task_store.transition(task.id, to: :failed,
645
- error: { class: error.class.name, message: error.message, stage: stage })
646
- emit(:task_failed, { task_id: task.id, error: error.class.name, message: error.message }, task: task)
647
- # RFC-0010 Fase 2: a FAILED delegation child still delivers — the parent
691
+ # The task's error record and the :task_failed event both carry the
692
+ # classification when the failure is a wrapped ProviderError (B9/A8):
693
+ # additive fields, absent for every other error.
694
+ classification = error.respond_to?(:classification) ? error.classification : {}
695
+ spec = { class: error.class.name, message: error.message, stage: stage }
696
+ spec = spec.merge(classification) unless classification.empty?
697
+ @task_store.transition(task.id, to: :failed, error: spec)
698
+ data = { task_id: task.id, error: error.class.name, message: error.message }
699
+ data = data.merge(classification) unless classification.empty?
700
+ emit(:task_failed, data, task: task)
701
+ # a FAILED delegation child still delivers — the parent
648
702
  # receives an error note as a new turn (never left hanging).
649
703
  finalize_delegation(task)
650
704
  nil
@@ -682,7 +736,7 @@ module Insika
682
736
  end
683
737
 
684
738
  # A Middleware short-circuited (did not call the terminal). Three cases:
685
- # · halt_response set -> GRACEFUL halt (RFC-0009 §3.1): the turn COMPLETES
739
+ # · halt_response set -> GRACEFUL halt: the turn COMPLETES
686
740
  # with a safe reply, reusing stages 8-9, without ever touching the LLM.
687
741
  # · halt_reason set -> halt-as-FAILURE (the pre-existing contract).
688
742
  # · neither -> contract violation (short-circuit with no signal).
@@ -701,16 +755,16 @@ module Insika
701
755
  raise Insika::TimeoutError.new("turn exceeded #{turn_timeout}s", stage: :turn)
702
756
  end
703
757
 
704
- # Builds the turn's mutable state (turn number, message, memory tenant, D2
758
+ # Builds the turn's mutable state (turn number, message, memory tenant,
705
759
  # turn context). before_task (hooks.around) may still rewrite it before stage 2.
706
760
  def build_turn_state(task, profile, resume_from)
707
761
  turn = resume_from ? resume_from.turn : 1
708
762
  state = TurnState.new(task: task, profile: profile, turn: turn,
709
763
  message: extract_message(task))
710
- state.tenant = memory_tenant(task) # WRITE-path memory scope (`remember`); =chat (D3)
711
- state.turn_context = build_turn_context(task, profile, state) # data-tools' ctx.* (D2/G4)
764
+ state.tenant = memory_tenant(task) # WRITE-path memory scope (`remember`); =chat
765
+ state.turn_context = build_turn_context(task, profile, state) # data-tools' ctx.*
712
766
  state.resumed = !resume_from.nil? # EdgeLimiter: an admitted turn is never re-counted
713
- # RFC-0015: resolved for the RUN, not per message, so what the turn accepts cannot
767
+ # resolved for the RUN, not per message, so what the turn accepts cannot
714
768
  # change under it. Same cost as the EdgeLimiter's per-turn resolution. Only for a
715
769
  # SESSION turn: steering needs a session to arrive through, and resolving here for a
716
770
  # one-shot would make an unrelated turn fail on a queue key it can never use.
@@ -763,9 +817,87 @@ module Insika
763
817
  state.requires_approval = resolution.requires_approval
764
818
  state.allowed_tools = wrap_tools(assemble_tool_instances(resolution.allowed_tools, state), state, skip)
765
819
  state.allowed_skills = resolution.allowed_skills
820
+ record_context_trace(task, state)
821
+ announce_context_skills(task, state)
766
822
  drain_and_maybe_suspend(task, actor)
767
823
  end
768
824
 
825
+ # one entry per turn in the ContextTraceStore — tokens per
826
+ # category (the demodulized provider id), the tools-schema estimate and the
827
+ # budget verdict. Counts and ids ONLY, never content. nil store = off; the
828
+ # store itself rescues everything (the trace never breaks the turn).
829
+ def record_context_trace(task, state)
830
+ return unless @context_trace_store && task.session_id
831
+
832
+ package = state.context
833
+ # A custom builder that does not produce the full package (fragments +
834
+ # budget) simply has no breakdown to record — never an error.
835
+ return unless package.respond_to?(:fragments) && package.respond_to?(:budget)
836
+
837
+ categories = package.fragments.each_with_object({}) do |f, acc|
838
+ c = (acc[context_category(f.source)] ||= { tokens: 0, fragments: 0, pinned: 0 })
839
+ c[:tokens] += f.tokens || 0
840
+ c[:fragments] += 1
841
+ c[:pinned] += (f.tokens || 0) if f.pinned
842
+ # WHICH skills/tools the fragment carried and WHY — ids only, still
843
+ # content-free. Without this the trace proves a turn injected N tokens of
844
+ # skill but not which ones, and deterministic activation is unauditable
845
+ # after the fact.
846
+ labels = Array(f.labels)
847
+ (c[:labels] ||= []).concat(labels) unless labels.empty?
848
+ end
849
+ @context_trace_store.record(
850
+ session_id: task.session_id,
851
+ entry: { task_id: task.id, turn: state.turn, at: Time.now.utc.iso8601,
852
+ cap: package.budget[:cap], used: package.budget[:used],
853
+ evicted: package.budget[:evicted], categories: categories,
854
+ tools: { count: state.allowed_tools.size,
855
+ tokens: estimate_tools_tokens(state.allowed_tools) } }
856
+ )
857
+ end
858
+
859
+ # Skill bodies that reached the prompt WITHOUT a tool call (`triggers:` or
860
+ # `skills_eager`) leave no trace in the transcript: there is no load_skill to
861
+ # render, so an active skill looked exactly like an absent one.
862
+ #
863
+ # Emitted HERE rather than in the provider because only the Executor holds the
864
+ # correlation the Studio's SSE filters on — an event whose meta lacks `task_id`
865
+ # never reaches a task-scoped subscriber (EventStream::Subscription#matches?).
866
+ # `skills` (plural, with reasons) marks the CONTEXT path; the load_skill tool
867
+ # emits the same type with a singular `name`, and the Studio must not conflate them.
868
+ #
869
+ # Read from `package.fragments`, which is POST-BUDGET: a body the cut evicted is
870
+ # not in the prompt, and announcing it as active would make the one surface built
871
+ # to tell the truth the one that lies. Eviction is reported by the trace's own
872
+ # `evicted` list, never as an activation.
873
+ SKILL_BODY_CATEGORY = "skilltrigger"
874
+
875
+ def announce_context_skills(task, state)
876
+ package = state.context
877
+ return unless package.respond_to?(:fragments)
878
+
879
+ skills = Array(package.fragments)
880
+ .select { |f| context_category(f.source) == SKILL_BODY_CATEGORY }
881
+ .flat_map { |f| Array(f.labels) }
882
+ .map { |l| { name: l["name"], reason: l["reason"] || "pack" } }
883
+ .uniq
884
+ return if skills.empty?
885
+
886
+ emit(:skill_activated, { skills: skills, source: "context" }, task: task)
887
+ end
888
+
889
+ # "Insika::Context::Providers::Prompt" -> "prompt" (a plugin provider keeps
890
+ # its own demodulized name — still content-free).
891
+ def context_category(source) = source.to_s.split("::").last.to_s.downcase
892
+
893
+ # Same yardstick as the fragments (TokenEstimator), so the categories are
894
+ # comparable. `parameters` is not guaranteed JSON-safe — inspect it.
895
+ def estimate_tools_tokens(tools)
896
+ TokenEstimator.estimate(tools.map { |t| "#{t.name} #{t.description} #{t.parameters.inspect}" }.join(" "))
897
+ rescue StandardError
898
+ 0
899
+ end
900
+
769
901
  # Stages 5-9 (inside the Middleware wrap): assemble chat, the single agent
770
902
  # interaction, persistence, terminal event. `st` is the Middleware-yielded state.
771
903
  def run_turn_body(task, profile, st, actor, timing = nil)
@@ -775,11 +907,11 @@ module Insika
775
907
  unless workflow_turn?(task)
776
908
  st.chat = create_chat(profile, st)
777
909
  @chat_builder.assemble(st.chat, st, emit: ->(type, data) { emit(type, data, task: task) })
778
- # §11 R1: baseline = seeded-history size, before `ask` appends the turn.
910
+ # R1: baseline = seeded-history size, before `ask` appends the turn.
779
911
  st.chat_baseline = Array(st.chat.messages).size if st.chat.respond_to?(:messages)
780
912
  end
781
913
 
782
- # guardrails (RFC-0009 §3.2): per-turn stream redactor (nil = off).
914
+ # guardrails: per-turn stream redactor (nil = off).
783
915
  st.output_filter = @content_filter_factory&.call(st)
784
916
 
785
917
  # stage 6: the turn's single agent interaction (send_message -> chat.ask;
@@ -798,6 +930,15 @@ module Insika
798
930
  timing&.mark(:done)
799
931
  data = { task_id: task.id, content: content, usage: st.usage }
800
932
  data[:timing] = timing.to_h if timing # opt-in TTFB breakdown (INSIKA_TURN_TIMING)
933
+ # WS5: the agent declared it cannot proceed (signal_stuck). The turn still
934
+ # COMPLETES (its final message was published) — but the consumer must be able
935
+ # to act on that, so the contract carries it twice: a dedicated :turn_stuck
936
+ # event (subscribable) and an additive `outcome` sibling on the terminal event.
937
+ if (stuck = st.stuck_outcome)
938
+ emit(:turn_stuck, { task_id: task.id, agent: profile.id.to_s,
939
+ reason: stuck[:reason], message: content }, task: task)
940
+ data[:outcome] = :stuck
941
+ end
801
942
  emit(:task_completed, data, task: task)
802
943
  end
803
944
 
@@ -811,7 +952,7 @@ module Insika
811
952
  # workflow = a Ruby callable that orchestrates RubyLLM internally (RubyLLM
812
953
  # First). tools: are the SAME instances filtered by the Resolution and
813
954
  # enveloped (stage 7) — the workflow inherits timeout/side-effect/skip.
814
- # Item 22 / §4.4: the EXPOSED surface — the run (== task.id) is announced on
955
+ # the EXPOSED surface — the run (== task.id) is announced on
815
956
  # the stream (:workflow_started), the RETURN is validated against the
816
957
  # output_schema (WorkflowSchemaError -> :workflow_schema stage), and the
817
958
  # typed output is published (:workflow_completed).
@@ -826,32 +967,20 @@ module Insika
826
967
  emit(:workflow_completed, { run_id: task.id, workflow: definition.name, output: output }, task: task)
827
968
  output
828
969
  else
829
- filter = state.output_filter # RFC-0009 §3.2: nil = off (stream untouched)
970
+ filter = state.output_filter # nil = off (stream untouched)
830
971
  timing&.mark(:ask)
831
972
  # TurnOutput owns what the customer is allowed to read: chunks ride
832
973
  # :intermediate live and only the message that ENDS the turn is published as
833
- # :content. Registered on the chat (fresh per turn, so no callback leaks).
834
- output = TurnOutput.new(filter: filter, emit: ->(type, data) { emit(type, data, task: task) },
835
- public_intermediate: state.profile.stream_public?(:intermediate))
836
- state.chat.after_message { |message| output.message_ended(message) } if state.chat.respond_to?(:after_message)
837
- # RFC-0015 §5.2: `steer` only. Registered AFTER TurnOutput so the publishing
838
- # decision for a message is made before anything is appended after it — the
839
- # gem's callbacks are additive and run in registration order.
840
- install_steer_injector(task, state)
841
-
842
- # `asked` is what the provider returned, BEFORE the :agent after-hook had a
843
- # chance to replace it — the only way to tell an explicit substitution from
844
- # the ordinary "the hook returned what it received".
974
+ # :content. Registered on the chat (fresh per turn/attempt, no leak).
975
+ # With WS3 reliability the attempts build their own chats + outputs.
976
+ output = nil
845
977
  asked = nil
846
978
  response = @hooks.around(:agent, state) do |s|
847
- public_thinking = state.profile.stream_public?(:thinking)
848
- asked = s.chat.ask(s.message) do |chunk|
849
- emit_thinking(chunk, task, public: public_thinking)
850
- next unless chunk.content
851
-
852
- timing&.mark(:first_token) # first-write-wins -> the PROVIDER's TTFB
853
- output.push(chunk.content)
854
- end
979
+ result = @reliability ? run_reliable_ask(task, s, filter, timing)
980
+ : run_single_ask(task, s, filter, timing)
981
+ output = result[:output]
982
+ asked = result[:asked]
983
+ result[:response]
855
984
  end
856
985
  # release the redactor's retained tail (a value that never completed into a
857
986
  # match is emitted redacted-if-needed, not lost) before reading anything back.
@@ -862,7 +991,7 @@ module Insika
862
991
  # was working used to be observed at stage 8 — AFTER `:content` had already been
863
992
  # published — so the customer read the answer of a turn that then terminated
864
993
  # `:cancelled` and persisted nothing: text delivered, transcript silent about it.
865
- # Honoring it here is what makes `interrupt` (RFC-0015 §6.4) mean anything, and it
994
+ # Honoring it here is what makes `interrupt` mean anything, and it
866
995
  # is a safe boundary: the tool batch is finished and nothing is half applied.
867
996
  # A `:pause` is deliberately NOT honored here (drain!, not the suspending form):
868
997
  # holding a completed answer for an operator would strand it unpublished.
@@ -872,7 +1001,168 @@ module Insika
872
1001
  end
873
1002
  end
874
1003
 
875
- # RFC-0015 §5.2 — wires the tool-batch boundary that lets a message which arrived
1004
+ # stage 6, plain path: the single ask on the assembled chat. Fresh TurnOutput
1005
+ # + steer wiring per interaction (registered on state.chat, which the solve
1006
+ # already assembled). -> { response:, asked:, output: }.
1007
+ def run_single_ask(task, state, filter, timing)
1008
+ output = new_turn_output(task, state, filter)
1009
+ wire_chat_output(task, state, output)
1010
+ asked = ask_on(task, state, state.chat, output, timing)
1011
+ { response: asked, asked: asked, output: output }
1012
+ end
1013
+
1014
+ # stage 6, WS3 path: the Reliability coordinator drives retries, backoff,
1015
+ # circuit breaker and the fallback rotation. Each ATTEMPT gets a fresh chat
1016
+ # + output (a failed `ask` leaves its message in the chat, so re-asking the
1017
+ # same one would double the input) and, on a fallback, state.model_selection
1018
+ # follows — the turn's usage is attributed to the model that actually spoke
1019
+ # ("contabilizado no trace"). -> { response:, asked:, output: }.
1020
+ def run_reliable_ask(task, state, filter, timing)
1021
+ policy = state.profile.respond_to?(:reliability) ? state.profile.reliability : nil
1022
+ return run_single_ask(task, state, filter, timing) if policy.nil? || @reliability.nil?
1023
+
1024
+ attempt_output = nil
1025
+ attempt_asked = nil
1026
+ primary = state.model_selection
1027
+ response = @reliability.call(
1028
+ policy: policy, tenant: task_tenant(task), agent: state.profile.id.to_s,
1029
+ selection: primary, chain: reliability_chain(state)
1030
+ ) do |selection, tries|
1031
+ first_attempt = state.chat && selection == primary && tries == 1
1032
+ if selection != primary
1033
+ state.model_selection = selection # attribution follows the fallback
1034
+ end
1035
+ unless first_attempt
1036
+ chat = build_attempt_chat(state, selection)
1037
+ state.chat = chat
1038
+ end
1039
+ attempt_output = new_turn_output(task, state, filter)
1040
+ wire_chat_output(task, state, attempt_output)
1041
+ attempt_asked = ask_on(task, state, state.chat, attempt_output, timing)
1042
+ attempt_asked
1043
+ end
1044
+ { response: response, asked: attempt_asked, output: attempt_output }
1045
+ end
1046
+
1047
+ # The fallback chain for WS3: profile's `reliability["fallback"]` refs first,
1048
+ # then the platform-resolved fallbacks (ModelSelection). Each node is a
1049
+ # ModelSelection (the SAME duck the primary is — usage attribution and
1050
+ # apply_params just work), source: :fallback, params inherited from the
1051
+ # primary. Deduped by ref, primary excluded.
1052
+ def reliability_chain(state)
1053
+ primary = state.model_selection
1054
+ refs = Array((state.profile.reliability || {})["fallback"]).map(&:to_s)
1055
+ nodes = refs.filter_map { |r| parse_model_ref(r) }.reject { |n| n[:model].to_s.empty? }
1056
+ nodes.concat(Array(primary.fallbacks).map { |f| { model: f[:model], provider: f[:provider] } })
1057
+ seen = { ref_of(primary) => true }
1058
+ nodes.filter_map do |node|
1059
+ ref = model_ref(node)
1060
+ # normalize "model" vs "provider/model": a provider-less ref IS the same
1061
+ # physical model as any known "provider/model" spelling of it — the same
1062
+ # model must never be tried twice just because one spelling omits the
1063
+ # provider (WS3: fallback ["deepseek-v4-flash"] under primary
1064
+ # deepseek/deepseek-v4-flash used to re-ask the dropped primary). A
1065
+ # qualified ref still matches exactly.
1066
+ duplicate = ref.include?("/") ? seen[ref]
1067
+ : seen.keys.any? { |known| known.split("/").last == ref }
1068
+ next if duplicate
1069
+
1070
+ seen[ref] = true
1071
+ Insika::ModelSelection.new(model: node[:model], provider: node[:provider],
1072
+ source: :fallback, params: primary.params, fallbacks: [])
1073
+ end
1074
+ end
1075
+
1076
+ def ref_of(selection) = model_ref(selection)
1077
+
1078
+ # "provider/model" for any selection duck (ModelSelection | { model:, provider: }).
1079
+ def model_ref(selection)
1080
+ model = selection.respond_to?(:model) ? selection.model.to_s : selection[:model].to_s
1081
+ provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
1082
+ provider ? "#{provider}/#{model}" : model
1083
+ end
1084
+
1085
+ # "provider/model" -> { model:, provider: }; "model" -> { model:, provider: nil }.
1086
+ def parse_model_ref(entry)
1087
+ s = entry.to_s.strip
1088
+ return nil if s.empty?
1089
+
1090
+ if s.include?("/")
1091
+ provider, model = s.split("/", 2)
1092
+ { model: model, provider: presence_or_nil(provider)&.to_sym }
1093
+ else
1094
+ { model: s, provider: nil }
1095
+ end
1096
+ end
1097
+
1098
+ def presence_or_nil(value)
1099
+ v = value.to_s.strip
1100
+ v.empty? ? nil : v
1101
+ end
1102
+
1103
+ # A fresh chat for a retry/fallback attempt: REASSEMBLED from the same turn
1104
+ # state (the seed history is identical), baseline reset -> the transcript
1105
+ # recorded from than point is the attempt that spoke.
1106
+ def build_attempt_chat(state, selection)
1107
+ chat = build_chat(selection, state.model_selection)
1108
+ @chat_builder.assemble(chat, state, emit: ->(type, data) { emit(type, data, task: state.task) })
1109
+ state.chat_baseline = Array(chat.messages).size if chat.respond_to?(:messages)
1110
+ chat
1111
+ end
1112
+
1113
+ def new_turn_output(task, state, filter)
1114
+ TurnOutput.new(filter: filter, emit: ->(type, data) { emit(type, data, task: task) },
1115
+ public_intermediate: state.profile.stream_public?(:intermediate))
1116
+ end
1117
+
1118
+ # The message-boundary + steer wiring ON the current chat. Registered
1119
+ # AFTER TurnOutput so the publishing decision for a message is made before
1120
+ # anything is appended after it — the gem's callbacks are additive and run
1121
+ # in registration order.
1122
+ def wire_chat_output(task, state, output)
1123
+ chat = state.chat
1124
+ chat.after_message { |message| output.message_ended(message) } if chat.respond_to?(:after_message)
1125
+ install_steer_injector(task, state)
1126
+ end
1127
+
1128
+ # The ask itself, chunk-by-chunk (WS3 attempts and the plain path share it).
1129
+ # With INSIKA_TURN_TIMING the FIRST content chunk also emits the live
1130
+ # :ttft event — the streaming envelope's TTFB signal (WS6), additive. The
1131
+ # emit is gated to that first chunk: a probe proved the old code re-emitted
1132
+ # :ttft on EVERY content chunk (3 chunks = 3 insika.ttft frames); the spec
1133
+ # passed because FakeChat emits a single chunk.
1134
+ def ask_on(task, state, chat, output, timing)
1135
+ public_thinking = state.profile.stream_public?(:thinking)
1136
+ ttft_sent = false
1137
+ chat.ask(state.message) do |chunk|
1138
+ emit_thinking(chunk, task, public: public_thinking)
1139
+ next unless chunk.content
1140
+
1141
+ timing&.mark(:first_token) # first-write-wins -> the PROVIDER's TTFB
1142
+ unless ttft_sent
1143
+ emit_ttft(task, timing) if timing
1144
+ ttft_sent = true
1145
+ end
1146
+ output.push(chunk.content)
1147
+ end
1148
+ end
1149
+
1150
+ # The provider's TTFB as a live event (data: ttft_ms) — only under
1151
+ # INSIKA_TURN_TIMING, so absent by default (parity).
1152
+ def emit_ttft(task, timing)
1153
+ ttft = timing.to_h[:ttft_ms]
1154
+ return if ttft.nil?
1155
+
1156
+ @event_stream.emit(Insika::Event.new(
1157
+ type: :ttft, data: { ttft_ms: ttft },
1158
+ meta: { task_id: task.id, session_id: task.session_id,
1159
+ at: Time.now.utc.iso8601 }
1160
+ ))
1161
+ rescue StandardError
1162
+ nil
1163
+ end
1164
+
1165
+ # wires the tool-batch boundary that lets a message which arrived
876
1166
  # mid-run enter the conversation. No-op unless the agent asked for `steer`: an
877
1167
  # unregistered callback is the difference between a feature that is off and one that
878
1168
  # is on and finds nothing.
@@ -948,9 +1238,9 @@ module Insika
948
1238
  #
949
1239
  # Three deliberate omissions:
950
1240
  # · the guardrail filter is NOT applied — it accumulates the PERSISTED content
951
- # (D3), and pushing reasoning through it would corrupt the turn's answer;
1241
+ # and pushing reasoning through it would corrupt the turn's answer;
952
1242
  # · `timing.mark(:first_token)` stays on the content chunks — ttft is the
953
- # PROVIDER's first token (item 34's baselines measure that, not the first
1243
+ # PROVIDER's first token ('s baselines measure that, not the first
954
1244
  # thought, and not when TurnOutput publishes the answer);
955
1245
  # · nothing is persisted — the reasoning is not part of the conversation.
956
1246
  #
@@ -967,7 +1257,7 @@ module Insika
967
1257
  emit(:thinking, data, task: task)
968
1258
  end
969
1259
 
970
- # Annotates the usage with the RESOLVED model-selection source (v2, §10):
1260
+ # Annotates the usage with the RESOLVED model-selection source:
971
1261
  # where the model came from (:chat/:agent/:platform_default) travels alongside
972
1262
  # the resolved model id (from the provider) into the terminal event/Telemetry,
973
1263
  # so billing/telemetry can attribute the turn to a config layer. nil usage
@@ -994,7 +1284,7 @@ module Insika
994
1284
  if response.respond_to?(:cached_tokens) && response.cached_tokens
995
1285
  usage[:cached_tokens] = response.cached_tokens.to_i # cache_read_input_tokens
996
1286
  end
997
- # §11 R3: prompt-cache WRITE tokens (Anthropic cache_creation_input_tokens),
1287
+ # R3: prompt-cache WRITE tokens (Anthropic cache_creation_input_tokens),
998
1288
  # billed at ~1.25x. Reported so the first (write) turn vs later (read) turns
999
1289
  # are distinguishable in telemetry/usage.
1000
1290
  if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens
@@ -1022,7 +1312,7 @@ module Insika
1022
1312
 
1023
1313
  def build_context_request(task, profile, state, resume_from)
1024
1314
  session = task.session_id ? @session_store.find(task.session_id) : nil
1025
- state.session = session # create_chat reads it for the per-chat model pin (§10)
1315
+ state.session = session # create_chat reads it for the per-chat model pin
1026
1316
  hist = command_history(task)
1027
1317
  # `vars` reconciles the seam (the Request/Session provider already
1028
1318
  # called request.vars): session metadata + the explicit `history` in the
@@ -1036,7 +1326,7 @@ module Insika
1036
1326
  checkpoint: resume_from, tenant: command_tenant(task), vars: vars)
1037
1327
  end
1038
1328
 
1039
- # :task_started payload. Carries the EXPLICIT command tenant (item 16 / P4) so
1329
+ # task_started payload. Carries the EXPLICIT command tenant so
1040
1330
  # the observability convention can group by it — the one operator-set label that
1041
1331
  # is not derivable from the task itself. Omitted when absent: the terminal
1042
1332
  # events keep their shape and no consumer sees a null it never saw before. NOT
@@ -1062,7 +1352,7 @@ module Insika
1062
1352
  Coercion.presence(rebuild_command(task).payload["origin"])
1063
1353
  end
1064
1354
 
1065
- # Engine memory scope (D3): the Command's EXPLICIT tenant wins (multi-merchant
1355
+ # Engine memory scope: the Command's EXPLICIT tenant wins (multi-merchant
1066
1356
  # override); otherwise the SESSION (=chat) — engine-owner memory is per-chat.
1067
1357
  # Symmetric to the READ path (Memory provider). One-shot with no tenant -> nil
1068
1358
  # (_default). It is NOT the <request_context> tenant (that follows
@@ -1071,35 +1361,35 @@ module Insika
1071
1361
  command_tenant(task) || task.session_id
1072
1362
  end
1073
1363
 
1074
- # Turn context (Phase 6/D2/G4): the ids the data-tools resolve via
1364
+ # Turn context: the ids the data-tools resolve via
1075
1365
  # {{ctx.*}} to emit X-Chat-Id/X-Store-Id/X-Agent-Id to /api/internal/*. They
1076
1366
  # come from the TURN, never from the model args (R2). chat_id = the session
1077
1367
  # (the /v1/responses adapter creates the session with id = user = chat.id);
1078
1368
  # tenant = the Command tenant (memory) OR chat_id (drop-in default); agent_id =
1079
1369
  # profile; store_id = the profile metadata (stable per store, from the pack).
1080
1370
  # Absent fields -> nil (the data-tool emits an empty header; in the pilot the
1081
- # profile carries store_id). Generic: nothing here mentions achei-b2b (NF1).
1371
+ # profile carries store_id). Generic: nothing here mentions a consumer.
1082
1372
  def build_turn_context(task, profile, state)
1083
1373
  {
1084
1374
  chat_id: task.session_id,
1085
1375
  agent_id: profile.id,
1086
1376
  tenant: state.tenant, # already = command_tenant || session_id (memory_tenant)
1087
1377
  store_id: profile.store_id,
1088
- # RFC-0010: current delegation depth (0 for a top-level turn). Carried in
1378
+ # current delegation depth (0 for a top-level turn). Carried in
1089
1379
  # the child command's payload by run_subagent; read here so the child's OWN
1090
1380
  # spawn_subagent tool sees depth+1 and the runtime cap holds down the chain.
1091
1381
  delegation_depth: delegation_depth(task)
1092
1382
  }
1093
1383
  end
1094
1384
 
1095
- # Delegation depth of THIS turn (RFC-0010): the value run_subagent stamped in
1385
+ # Delegation depth of THIS turn: the value run_subagent stamped in
1096
1386
  # the child command, or 0 for a top-level turn. Integer-coerced (JSON round-trip
1097
1387
  # of the persisted command may deliver a String).
1098
1388
  def delegation_depth(task)
1099
1389
  rebuild_command(task).payload["delegation_depth"].to_i
1100
1390
  end
1101
1391
 
1102
- # RFC-0010 R2: environment (model/thinking) inherits as DEFAULT — the child's
1392
+ # R2: environment (model/thinking) inherits as DEFAULT — the child's
1103
1393
  # explicit value wins; when absent, seed from the parent's RESOLVED selection.
1104
1394
  # Capacity fields are untouched (R1: the child profile is used as-is). Returns
1105
1395
  # the child profile unchanged when there is nothing to inherit.
@@ -1120,7 +1410,7 @@ module Insika
1120
1410
  child_profile.with(model: model, provider: provider, params: params)
1121
1411
  end
1122
1412
 
1123
- # Single validation path for a delegation (RFC-0010) — shared by run_subagent
1413
+ # Single validation path for a delegation — shared by run_subagent
1124
1414
  # and the fan-out run_subagents. `task` is {agent, message} (string OR symbol
1125
1415
  # keys — the model's args arrive string-keyed). Returns a resolved plan
1126
1416
  # { agent:, profile:, message:, depth: } or { agent:, error: } (the agent name is
@@ -1193,7 +1483,7 @@ module Insika
1193
1483
  [child_session_id, child_task]
1194
1484
  end
1195
1485
 
1196
- # SYNC (Fase 1): spawns the child and AWAITS it on the parent's fiber, then
1486
+ # SYNC: spawns the child and AWAITS it on the parent's fiber, then
1197
1487
  # projects the terminal content. Direct `spawn` (not spawn_in_session): the
1198
1488
  # child session is brand-new, so there is no SessionActor contention — the child
1199
1489
  # is parented at turn_parent and the parent yields cooperatively on `wait`.
@@ -1204,7 +1494,7 @@ module Insika
1204
1494
  project_child_result(child_task.id, child_session_id, child_profile.id, parent_state)
1205
1495
  end
1206
1496
 
1207
- # ASYNC (Fase 2): persists a Delegation, spawns the child NON-blocking, and
1497
+ # ASYNC: persists a Delegation, spawns the child NON-blocking, and
1208
1498
  # returns a dispatch ack immediately — the parent turn ends without waiting. The
1209
1499
  # child's terminal hook (finalize_delegation) delivers the result later, as a
1210
1500
  # NEW turn on the parent session.
@@ -1253,7 +1543,7 @@ module Insika
1253
1543
  exec.error && (exec.error["message"] || exec.error[:message])
1254
1544
  end
1255
1545
 
1256
- # RFC-0010 Fase 2 — terminal hook: when a turn ends (success OR failure), if the
1546
+ # terminal hook: when a turn ends (success OR failure), if the
1257
1547
  # task is the child of an ASYNC delegation, capture its result and deliver it to
1258
1548
  # the parent. Fires for both a normal completion and a resumed one (recovery),
1259
1549
  # so it needs no live watcher fiber. No-op without a delegation_store or when the
@@ -1337,12 +1627,14 @@ module Insika
1337
1627
  command: rebuild_command(task),
1338
1628
  context: state.context,
1339
1629
  candidate_tools: @tool_registry.entries,
1340
- candidate_skills: @skill_catalog.effective(profile.skills)
1630
+ # agent: so a specialized skill reaches the policy as the agent's own version
1631
+ # (same name, its body) instead of the shared one it overrides.
1632
+ candidate_skills: @skill_catalog.effective(profile.skills, agent: profile.id)
1341
1633
  )
1342
1634
  end
1343
1635
 
1344
1636
  # The Task persists the Command as a Hash; the WorkflowAllowlist needs
1345
- # a Command with #type (Symbol) and #payload. §11 B2: the SINGLE point that
1637
+ # a Command with #type (Symbol) and #payload.: the SINGLE point that
1346
1638
  # reconciles the string||symbol keys of the persisted command — payload/meta
1347
1639
  # keys are stringified ONCE here, so every reader (command_type/workflow_name/
1348
1640
  # extract_message/command_history/command_tenant) works with string keys.
@@ -1362,7 +1654,7 @@ module Insika
1362
1654
  (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
1363
1655
  end
1364
1656
 
1365
- # Stage-3-tail tool assembly — delegated to ToolAssembly (§11 B5). Kept as
1657
+ # Stage-3-tail tool assembly — delegated to ToolAssembly. Kept as
1366
1658
  # thin private methods so the existing spec contract (executor.send(:...))
1367
1659
  # stays intact and run_pipeline reads unchanged.
1368
1660
  def resolve_capabilities(profile, context) = @tool_assembly.resolve_capabilities(profile, context)
@@ -1403,7 +1695,7 @@ module Insika
1403
1695
  ))
1404
1696
  end
1405
1697
 
1406
- # GRACEFUL halt (RFC-0009 §3.1): a Middleware short-circuited with a safe reply.
1698
+ # GRACEFUL halt: a Middleware short-circuited with a safe reply.
1407
1699
  # The turn COMPLETES — same stages 8-9 as a normal turn — but the "assistant
1408
1700
  # content" is the guardrail's safe response, produced with ZERO LLM calls. The
1409
1701
  # order mirrors a real turn so both the /v1/responses consumer (which reads the
@@ -1420,11 +1712,11 @@ module Insika
1420
1712
  }, task: task)
1421
1713
  end
1422
1714
  emit(:content, { delta: content }, task: task) unless content.empty?
1423
- # An EDGE-blocked turn (rate limit / token ceiling, item 33) completes but
1715
+ # An EDGE-blocked turn (rate limit / token ceiling) completes but
1424
1716
  # stays OUT of the session history: a flood at the wall must not bloat the
1425
1717
  # session nor evict real conversation from the context budget — the
1426
1718
  # :guardrail_blocked event is the audit trail. Content-guardrail blocks
1427
- # keep persisting (RFC-0009: the refusal is part of the conversation).
1719
+ # keep persisting (the refusal is part of the conversation).
1428
1720
  # The reply is the guardrail's, produced with zero LLM calls — so it is NOT the
1429
1721
  # agent talking, and a report that counts it as the agent repeating itself is
1430
1722
  # reading the engine's own canned text (the `safe_reply` finding exists exactly
@@ -1452,7 +1744,7 @@ module Insika
1452
1744
  # Recovery re-executes the already-saved turn (safe thanks to the side-effect
1453
1745
  # recording).
1454
1746
  #
1455
- # CHECKPOINT vs SESSION — the two stores DIVERGE by design (§11 R2c):
1747
+ # CHECKPOINT vs SESSION — the two stores DIVERGE by design (R2c):
1456
1748
  # · Checkpoint.messages = flatten_history(context.history) + new_messages,
1457
1749
  # i.e. "what the model actually SAW this turn" AFTER budget eviction
1458
1750
  # (context.history is the post-budget assembly). It is the deterministic
@@ -1493,12 +1785,12 @@ module Insika
1493
1785
 
1494
1786
  emit(:checkpoint_created, { task_id: task.id, turn: state.turn + 1 }, task: task)
1495
1787
 
1496
- # RFC-0010 Fase 2: if this completed turn is an ASYNC delegation child,
1788
+ # if this completed turn is an ASYNC delegation child,
1497
1789
  # deliver its result to the parent as a NEW turn. No-op for a normal turn
1498
1790
  # (not a delegation child) or without a delegation_store.
1499
1791
  finalize_delegation(task)
1500
1792
 
1501
- # RFC-0011 §6.5: if this turn CAME IN through a Shape B channel, its answer
1793
+ # if this turn CAME IN through a Shape B channel, its answer
1502
1794
  # has to travel out of band. Same terminal hook, next door to the delegation
1503
1795
  # one, for the same reason: it fires for a fresh turn and a recovered one.
1504
1796
  finalize_channel_delivery(task, content)
@@ -1508,7 +1800,7 @@ module Insika
1508
1800
  # turn's TRANSPORT (`channel:<id>` on the persisted command), not the session:
1509
1801
  # a session belongs to the channel forever, but a message an operator types into
1510
1802
  # the Studio playground against that same session must not reach the customer.
1511
- # Human handoff is not a product feature (`FOLLOWUP §14.6`), and it would be a
1803
+ # Human handoff is not a product feature (``), and it would be a
1512
1804
  # surprising way to acquire one.
1513
1805
  #
1514
1806
  # The consequence, stated rather than discovered later: a turn the ENGINE
@@ -1553,15 +1845,15 @@ module Insika
1553
1845
  transport.start_with?("channel:") ? transport.delete_prefix("channel:") : nil
1554
1846
  end
1555
1847
 
1556
- # Truncation cap for a persisted `role: tool` content (§11 R1): the transcript
1848
+ # Truncation cap for a persisted `role: tool` content (R1): the transcript
1557
1849
  # keeps the loop coherent; the FULL result lives in the ToolTraceStore (viewer).
1558
1850
  TOOL_CONTENT_CAP = 4_000
1559
1851
 
1560
- # The turn's messages in the ADDITIVE string-keyed format (§11 R1). Prefers the
1852
+ # The turn's messages in the ADDITIVE string-keyed format (R1). Prefers the
1561
1853
  # real chat transcript (`chat.messages.drop(baseline)`) so tool calls/results
1562
1854
  # survive between turns; falls back to the {user, assistant} pair when the chat
1563
1855
  # did not record the turn (workflow, graceful halt, or the specs' FakeChat).
1564
- # The final assistant text is the REDACTED `content` (output_filter, RFC-0009 D3),
1856
+ # The final assistant text is the REDACTED `content` (output_filter),
1565
1857
  # never the raw text the gem stored.
1566
1858
  # `origin` (MessageOrigin) travels on the turn's Command and is stamped on the
1567
1859
  # message it describes: the INCOMING one. It is absent for an ordinary turn, and
@@ -1637,7 +1929,7 @@ module Insika
1637
1929
  end
1638
1930
 
1639
1931
  # context.history may carry "eviction units" (an assistant+tool_results cycle
1640
- # grouped as one Array by the Session provider, §11 R1). Checkpoints store a
1932
+ # grouped as one Array by the Session provider, R1). Checkpoints store a
1641
1933
  # FLAT list — the provider regroups on read. Flatten one level; message Hashes
1642
1934
  # are untouched.
1643
1935
  def flatten_history(history) = Array(history).flatten(1)
@@ -1653,28 +1945,51 @@ module Insika
1653
1945
  require_relative "tools/remember"
1654
1946
  require_relative "tools/subagent"
1655
1947
  require_relative "tools/subagents"
1656
- # v2 resolution (§10): Chat pin > Agent model > platform default, model_policy
1948
+ require_relative "tools/stuck_signal"
1949
+ # v2 resolution: Chat pin > Agent model > platform default, model_policy
1657
1950
  # enforced, fallback chain resolved. Kept on the state for telemetry (usage).
1658
1951
  selection = @model_resolver.resolve(profile: profile, session: state.session)
1659
1952
  state.model_selection = selection
1953
+ build_chat(selection, selection)
1954
+ end
1955
+
1956
+ # The gem boundary: one chat for a model selection (the resolved primary or
1957
+ # a WS3 fallback node). The primary's generation params apply to the whole
1958
+ # chain (params_source: ModelSelection#apply_params).
1959
+ def build_chat(selection, params_source)
1960
+ model = selection.respond_to?(:model) ? selection.model : selection[:model]
1961
+ provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
1660
1962
  chat = (@llm || RubyLLM).chat(
1661
- model: selection.model,
1662
- provider: selection.provider,
1663
- assume_model_exists: selection.assume_model_exists?
1963
+ model: model,
1964
+ provider: provider,
1965
+ assume_model_exists: !provider.nil?
1664
1966
  )
1665
- selection.apply_params(chat) # temperature/max_tokens/thinking (per-agent, §10)
1967
+ params_source.apply_params(chat) # temperature/max_tokens/thinking (per-agent)
1666
1968
  chat
1667
1969
  end
1668
1970
 
1669
1971
  # Single emitter: an Event with meta and a monotonic seq per task. @seqs is not
1670
1972
  # cleared at the end of the task — the resume (new Execution) continues the
1671
- # numbering (reliable replay).
1973
+ # numbering (reliable replay). A task WITH a tenant (WS1) tags every event it
1974
+ # emits — the tenant-scoped /v1/events subscription filters on it (a control
1975
+ # event without a task has no tenant and never matches a tenant stream);
1976
+ # absent tenant -> the meta is byte-identical to before.
1672
1977
  def emit(type, data, task:)
1673
- @event_stream.emit(Insika::Event.new(
1674
- type: type, data: data,
1675
- meta: { task_id: task.id, session_id: task.session_id,
1676
- seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601 }
1677
- ))
1978
+ meta = { task_id: task.id, session_id: task.session_id,
1979
+ seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601 }
1980
+ tenant = task_tenant(task)
1981
+ meta[:tenant] = tenant unless tenant.nil?
1982
+ @event_stream.emit(Insika::Event.new(type: type, data: data, meta: meta))
1983
+ end
1984
+
1985
+ # The tenant stamped on the task's command (WS1), nil when the request was
1986
+ # operator-made. Cheap read on the persisted command hash — never rebuilds.
1987
+ def task_tenant(task)
1988
+ command = task.respond_to?(:command) ? task.command : nil
1989
+ return nil unless command.is_a?(Hash)
1990
+
1991
+ meta = command["meta"] || command[:meta] || {}
1992
+ meta["tenant"] || meta[:tenant]
1678
1993
  end
1679
1994
  end
1680
1995
  end