insika 0.1.0 → 0.3.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -19,7 +19,10 @@ 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, media: nil, media_output: nil,
24
+ grounding_enforcer: nil, cache_series_store: nil,
25
+ contact_store: nil, followup_store: nil, model_visible_trace_store: nil)
23
26
  @context_builder = context_builder
24
27
  @policy_engine = policy_engine
25
28
  @middleware = middleware
@@ -36,31 +39,63 @@ module Insika
36
39
  @pending_action_store = pending_action_store # approval gate
37
40
  @capability_registry = capability_registry # capability resolution (nil = off)
38
41
  @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 }.
42
+ # per-turn context breakdown (tokens by category + budget) for the
43
+ # Studio session card. nil = off (no record, zero overhead — parity).
44
+ @context_trace_store = context_trace_store
45
+ # the model-visible trace — what the provider received per
46
+ # (task, turn), captured at the chat boundary. nil = off (no record,
47
+ # zero overhead — parity).
48
+ @model_visible_trace_store = model_visible_trace_store
49
+ # Guardrails output filter: ->(state) { OutputFilter | nil }.
40
50
  # Injected by the Safety::Factory; nil = off (parity — the stream is untouched).
41
51
  # The INPUT guardrail is a Middleware (in the stack, not here); this is the seam
42
52
  # for the stream-side redaction the Executor owns.
43
53
  @content_filter_factory = content_filter_factory
44
- # RFC-0010 Fase 2: durable record of ASYNC delegations. nil = async
54
+ # durable record of ASYNC delegations. nil = async
45
55
  # delegation OFF (only the synchronous spawn_subagent works — parity). When
46
56
  # present, run_subagent(async: true) dispatches + returns immediately and the
47
57
  # child's result is delivered to the parent session as a NEW turn on completion.
48
58
  @delegation_store = delegation_store
49
- # RFC-0011 §6.5: outbound delivery for Shape B channels. nil = no channel
59
+ # outbound delivery for Shape B channels. nil = no channel
50
60
  # delivers out of band (parity — every surface today answers on the request's
51
61
  # own connection). When present, a turn that CAME IN through a channel writes
52
62
  # its answer to the outbox at the terminal and the dispatcher POSTs it.
53
63
  @channel_delivery = channel_delivery
54
- # RFC-0017 A2: the chat FACTORY this executor asks — a RubyLLM::Context (an
64
+ # the chat FACTORY this executor asks — a RubyLLM::Context (an
55
65
  # isolated config dup) when the graph owns its credentials, nil = the
56
66
  # process-wide RubyLLM constant (the historic single-graph deployment).
57
67
  # Duck-typed: Context#chat and RubyLLM.chat take the same keywords.
58
68
  @llm = llm
59
- # LLM config v2 (§10): resolves the model at turn start (Chat > Agent >
69
+ # stability for the turn's single agent interaction (WS3): retries /
70
+ # fallback / circuit breaker, all DATA on AgentProfile#reliability.
71
+ # nil = the plain single ask (parity).
72
+ @reliability = reliability
73
+ # WS9 media seam (nil = default built on first audio turn): ->(url) { text }.
74
+ @media = media
75
+ # WS9 (saída) media-generation seams: { image: ->(prompt, cfg) [part,
76
+ # usage], tts: ->(text, cfg) [part, usage] }. nil = the defaults (built
77
+ # lazily on first generation — RubyLLM + Net::HTTP behind lazy requires,
78
+ # the core stays gem-free at load). Injected by specs; a production graph
79
+ # that wants a non-RubyLLM backend injects its own lambdas.
80
+ @media_output = media_output
81
+ # the :enforce boundary step, called between stages 6 and 8.
82
+ # Defaults to a REAL enforcer (inert unless the profile's grounding.mode is
83
+ # :enforce — zero behavior change for parity) so an embedder that builds
84
+ # the Executor directly still gets the cut; `nil` stays injectable for
85
+ # stubs that want none.
86
+ @grounding_enforcer = grounding_enforcer || Insika::Safety::GroundingEnforcer.new
87
+ # the per-AGENT cache-hit series. nil = no series recorded
88
+ # (parity — the trace store still gets the per-turn entry when wired).
89
+ @cache_series_store = cache_series_store
90
+ # LLM config v2: resolves the model at turn start (Chat > Agent >
60
91
  # platform default) + model_policy + fallback chain. settings_store nil =
61
92
  # no platform layer (pre-v2 behavior: the agent's own model is used as-is).
62
93
  @model_resolver = ModelResolver.new(settings_store: settings_store)
63
- # RFC-0015 §4: the platform layer of the queue policy (nil = per-agent and
94
+ # the follow-up stores the ChatBuilder gates the
95
+ # schedule/cancel_followup tools on (nil = never wired — parity).
96
+ @contact_store = contact_store
97
+ @followup_store = followup_store
98
+ # the platform layer of the queue policy (nil = per-agent and
64
99
  # defaults only, which is `followup` with no window — today's behavior).
65
100
  @settings_store = settings_store
66
101
  # RubyLLM glue (stages 5-7): chat assembly delegated to ChatBuilder. Its
@@ -71,13 +106,27 @@ module Insika
71
106
  tool_registry: tool_registry, skill_catalog: skill_catalog,
72
107
  checkpoint_store: checkpoint_store, event_stream: event_stream, hooks: hooks,
73
108
  tool_catalog: tool_catalog, memory_store: memory_store,
74
- # RFC-0010: the ChatBuilder wires the spawn_subagent system tool (gated by
109
+ # load_skill is not enveloped, so it records its own trace entry.
110
+ tool_trace_store: tool_trace_store,
111
+ # the ChatBuilder wires the spawn_subagent system tool (gated by
75
112
  # profile.subagents) and hands it this Executor as the runner. `self` is not
76
113
  # yet fully built here, but the ChatBuilder only STORES it (used per-turn).
77
- subagent_runner: self
114
+ subagent_runner: self,
115
+ # WS9 (saída): the media-generation runner, same shape — the Executor
116
+ # owns the seams + usage accounting, the builder only wires the tools
117
+ # the turn's gates allow.
118
+ media_runner: self,
119
+ # the builder wires the briefing-write system tools gated by
120
+ # @session_store + profile.briefing_fields. nil = never wired (parity).
121
+ session_store: session_store,
122
+ # the builder wires the schedule/cancel_followup system
123
+ # tools gated by a parsed policy AND both stores present. nil = never
124
+ # wired (parity).
125
+ contact_store: contact_store,
126
+ followup_store: followup_store
78
127
  )
79
- # Stage-3-tail tool assembly (capability resolution, instantiation, D2
80
- # injection, dedup join, ToolEnvelope wrap) — extracted collaborator (§11 B5).
128
+ # Stage-3-tail tool assembly (capability resolution, instantiation,
129
+ # injection, dedup join, ToolEnvelope wrap) — extracted collaborator.
81
130
  @tool_assembly = ToolAssembly.new(
82
131
  tool_registry: tool_registry, capability_registry: capability_registry,
83
132
  event_stream: event_stream, checkpoint_store: checkpoint_store,
@@ -88,7 +137,7 @@ module Insika
88
137
  @supervised = false # serving mode? — see #turn_parent
89
138
  @supervisor = nil # lazy long-lived supervisor (created when serving)
90
139
  @session_actors = {} # session_id => SessionActor (FIFO queue)
91
- @draining = false # shutdown drain (RFC-0016 A3) — see #begin_drain!
140
+ @draining = false # shutdown drain — see #begin_drain!
92
141
  end
93
142
 
94
143
  # Turns on SERVING mode: the composition root's serving arm (serve.rb /
@@ -102,7 +151,31 @@ module Insika
102
151
  # concurrency).
103
152
  attr_accessor :supervised
104
153
 
105
- # RFC-0016 A3: closes the TURN intake for shutdown. Armed by Insika::Shutdown
154
+ # the periodic tick (outbox drain + stale recovery sweep), wired
155
+ # by the graph AFTER the bus exists (the tick's recovery half dispatches
156
+ # through it). nil = no tick (parity — recovery stays boot-only). When
157
+ # present and serving, it starts as a child of the turn supervisor (see
158
+ # #turn_parent).
159
+ attr_accessor :tick
160
+
161
+ # The WS6 alert dispatcher: answers budget_warning / breaker_open /
162
+ # delivery_failed with a durable webhook delivery. Started as a child of
163
+ # the turn supervisor in serving mode (like the tick); nil = no alerts.
164
+ attr_accessor :alert_dispatcher
165
+
166
+ # the distillation engine — the tick-duty that finds idle
167
+ # customer sessions and distills them on its own worker fiber (a child of
168
+ # the turn supervisor, like the tick). nil = distillation off (parity —
169
+ # nothing scans, nothing distills).
170
+ attr_accessor :distill_engine
171
+
172
+ # the harvest engine — the tick-duty that finds idle,
173
+ # unmined sessions and mines them on its own worker fiber (a child of the
174
+ # turn supervisor, like the tick). nil = harvest off (parity — nothing
175
+ # scans, nothing mines).
176
+ attr_accessor :harvest_engine
177
+
178
+ # closes the TURN intake for shutdown. Armed by Insika::Shutdown
106
179
  # when the process is asked to stop: from here on a new top-level turn is left
107
180
  # `:queued` (durable — the next boot's recovery replays it) instead of
108
181
  # spawning, while the in-flight turns run to their natural end. One-way by
@@ -203,12 +276,17 @@ module Insika
203
276
 
204
277
  # Stage 1 (async part): creates the actor, registers it and fires the fiber.
205
278
  # Called by the turn handlers (SendMessage/ResumeTask/TriggerWorkflow).
206
- def spawn(task, profile:, resume_from: nil)
279
+ #
280
+ # `timing` is the channel clock a channel turn allocated at 202
281
+ # acceptance, already carrying `:inbound`; nil means the pipeline allocates its
282
+ # own (resume, engine-initiated, non-channel). `mark` is first-write-wins, so
283
+ # re-marking `:inbound` in the pipeline is a no-op on a threaded clock.
284
+ def spawn(task, profile:, resume_from: nil, timing: nil)
207
285
  raise Insika::ValidationError, "task already running: #{task.id}" if running?(task.id)
208
286
 
209
287
  actor = TaskActor.new(task_id: task.id, parent: turn_parent)
210
288
  @running[task.id] = actor
211
- actor.run { execute(task, profile: profile, resume_from: resume_from, actor: actor) }
289
+ actor.run { execute(task, profile: profile, resume_from: resume_from, actor: actor, timing: timing) }
212
290
  task.id
213
291
  end
214
292
 
@@ -216,8 +294,8 @@ module Insika
216
294
  # session_id is SERIALIZED in that session's SessionActor queue (one at a
217
295
  # time); without a session_id (one-shot/history) it goes straight to spawn
218
296
  # (standalone).
219
- def spawn_in_session(task, profile:, resume_from: nil)
220
- # RFC-0016 A3: the intake is closed. The task is already durable (:queued);
297
+ def spawn_in_session(task, profile:, resume_from: nil, timing: nil)
298
+ # the intake is closed. The task is already durable (queued);
221
299
  # answering with its id and spawning NOTHING is what "stops accepting new
222
300
  # turns" means — the next boot's recovery replays it. Subagent turns are NOT
223
301
  # gated (they spawn directly): a child of an in-flight parent is part of the
@@ -230,14 +308,15 @@ module Insika
230
308
  # owner awaits the turn). One-shot/history (no session_id)
231
309
  # never serialize.
232
310
  unless @supervised && task.session_id
233
- return spawn(task, profile: profile, resume_from: resume_from)
311
+ return spawn(task, profile: profile, resume_from: resume_from, timing: timing)
234
312
  end
235
313
 
236
314
  session_actor(task.session_id).enqueue(task, profile: profile, resume_from: resume_from,
237
- policy: queue_policy(profile, task.session_id))
315
+ policy: queue_policy(profile, task.session_id),
316
+ timing: timing)
238
317
  end
239
318
 
240
- # RFC-0015 §5.3 — the `collect` door, asked BEFORE a task is created.
319
+ # the `collect` door, asked BEFORE a task is created.
241
320
  # -> the task id the fragment joined, or nil (create a task and spawn as usual).
242
321
  #
243
322
  # Asking first is what keeps the store clean: creating a task and then
@@ -255,7 +334,7 @@ module Insika
255
334
  actor.collect(text)
256
335
  end
257
336
 
258
- # RFC-0015 §5.1 — the `steer` door: a message for a session whose turn is ALREADY
337
+ # the `steer` door: a message for a session whose turn is ALREADY
259
338
  # running is appended to that run instead of becoming a turn of its own.
260
339
  # -> the RUNNING task's id (the turn that will answer it), or nil (create a task and
261
340
  # spawn as usual, which is `followup`).
@@ -274,7 +353,8 @@ module Insika
274
353
  return nil unless session_actor&.alive?
275
354
 
276
355
  # No turn running (or one still at the door): there is nothing to steer INTO.
277
- # A turn at the door belongs to `collect`, which is a different mode.
356
+ # A turn at the door is the collect door's other window — a steer agent with a
357
+ # debounce merges there instead , so no message waits on either.
278
358
  task = session_actor.current_task
279
359
  return nil if task.nil?
280
360
  # A workflow turn orchestrates RubyLLM itself and has no Insika chat to append to
@@ -293,7 +373,7 @@ module Insika
293
373
  task.id
294
374
  end
295
375
 
296
- # RFC-0015 §6.4 — the `interrupt` door: the turn in flight is answering a question
376
+ # the `interrupt` door: the turn in flight is answering a question
297
377
  # the customer has already replaced, so it is abandoned and the new message becomes an
298
378
  # ordinary turn. -> the abandoned task's id, or nil (nothing was running).
299
379
  #
@@ -305,7 +385,7 @@ module Insika
305
385
  # `:cancel` is observed only at a stage boundary, so a tool call in flight runs to
306
386
  # completion and is recorded. Cancelling the not-yet-started calls of a batch would
307
387
  # 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`).
388
+ # tools failed when they did not (records the same boundary for `turn_timeout`).
309
389
  def interrupt_running(session_id, profile:, replaced_by: nil)
310
390
  return nil unless @supervised && session_id
311
391
 
@@ -329,7 +409,7 @@ module Insika
329
409
  # its own, by design — it owns scheduling, not persistence).
330
410
  attr_reader :task_store
331
411
 
332
- # RFC-0015 §8. Emitted by the SessionActor when a window closes having merged
412
+ # Emitted by the SessionActor when a window closes having merged
333
413
  # more than one fragment. `arrivals` are the ISO8601 times each fragment landed
334
414
  # — the ONLY record that they were separate messages, since a merged fragment
335
415
  # creates no task of its own. Ids and times, never content.
@@ -337,7 +417,7 @@ module Insika
337
417
  emit(:turn_coalesced, { task_id: task.id, merged: merged, arrivals: arrivals }, task: task)
338
418
  end
339
419
 
340
- # RFC-0015 §5.2 — a steered message the run could NOT absorb: no tool batch ever
420
+ # a steered message the run could NOT absorb: no tool batch ever
341
421
  # closed (a text-only turn), the batch ended in `halt_when`, the turn failed, or it
342
422
  # was cancelled. The message is a person's and must not evaporate, so it is released
343
423
  # as the next turn on this session — which is `followup`, arrived at late.
@@ -382,13 +462,13 @@ module Insika
382
462
  # its completion before returning — that is what serializes the session. A
383
463
  # turn error is already mapped to a terminal state inside its own fiber (single
384
464
  # capture); here we only ensure the session loop does not die.
385
- def run_serial(task, profile:, resume_from: nil)
386
- # RFC-0016 A3: a drain that started with turns already queued behind this
465
+ def run_serial(task, profile:, resume_from: nil, timing: nil)
466
+ # a drain that started with turns already queued behind this
387
467
  # session's current one must not keep feeding the loop — without this gate
388
468
  # the drain would only converge when the whole backlog ran out.
389
469
  return defer_turn(task) if @draining
390
470
 
391
- spawn(task, profile: profile, resume_from: resume_from)
471
+ spawn(task, profile: profile, resume_from: resume_from, timing: timing)
392
472
  @running[task.id]&.wait
393
473
  rescue Async::Stop
394
474
  raise # shutdown: propagate (ends the session loop)
@@ -408,15 +488,15 @@ module Insika
408
488
  nil
409
489
  end
410
490
 
411
- # RFC-0010 (item 21): runs a CHILD agent turn (called by Tools::Subagent during
491
+ # runs a CHILD agent turn (called by Tools::Subagent during
412
492
  # stage 6). Isolated context (fresh child session), capability NON-inheritance
413
493
  # (child profile resolved fresh), environment inheritance (model/thinking seeded
414
494
  # from the parent). NEVER raises: a bad agent/depth/child failure is a message
415
495
  # to the model, not a turn-killer.
416
496
  #
417
- # async:false (default, Fase 1) — SYNCHRONOUS: runs the child inside the parent's
497
+ # async:false (default) — SYNCHRONOUS: runs the child inside the parent's
418
498
  # 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
499
+ # async:true — DURABLE dispatch: spawns the child NON-blocking, persists
420
500
  # a Delegation, returns { dispatched:, agent:, session_id: } immediately; the
421
501
  # parent turn ends and the child's result is later delivered as a NEW turn on
422
502
  # the parent session (needs a delegation_store — else falls back to sync).
@@ -431,7 +511,7 @@ module Insika
431
511
  end
432
512
  end
433
513
 
434
- # RFC-0010 §A (fan-out): runs SEVERAL child turns IN PARALLEL and returns all
514
+ # (fan-out): runs SEVERAL child turns IN PARALLEL and returns all
435
515
  # results together, in the requested order. This is the real latency win — the
436
516
  # children overlap their provider waits on the reactor, so wall-clock ≈ the
437
517
  # slowest child, not the sum. Always sync-join (a combined result in the parent's
@@ -449,7 +529,7 @@ module Insika
449
529
  { results: spawn_all_and_project(plans, parent_state) }
450
530
  end
451
531
 
452
- # RFC-0010 Fase 2 (boot): reconciles ASYNC delegations after a crash so a
532
+ # (boot): reconciles ASYNC delegations after a crash so a
453
533
  # completed child's result is never lost. For each undelivered Delegation:
454
534
  # · child TERMINAL, not captured -> capture + deliver.
455
535
  # · child TERMINAL, captured (completed) -> deliver (crash before delivery).
@@ -471,7 +551,7 @@ module Insika
471
551
  { delivered: delivered }
472
552
  end
473
553
 
474
- # RFC-0011 §6.5 (boot): re-drives the outbound replies a previous process
554
+ # (boot): re-drives the outbound replies a previous process
475
555
  # recorded and never claimed. Records left `delivering` are NOT swept — that
476
556
  # process may have POSTed before it died, and re-sending is the duplicate the
477
557
  # claim exists to prevent. No-op without a channel_delivery.
@@ -483,7 +563,7 @@ module Insika
483
563
  end
484
564
 
485
565
  # Stages 2..9. Runs INSIDE the task's fiber.
486
- def execute(task, profile:, actor:, resume_from: nil)
566
+ def execute(task, profile:, actor:, resume_from: nil, timing: nil)
487
567
  # Resume of a crash orphan: the interrupted attempt's Execution was left OPEN
488
568
  # (the fiber died). The TaskStore forbids opening a second one while one is
489
569
  # open -> close the orphan as :interrupted before opening the N+1 (a new
@@ -497,7 +577,7 @@ module Insika
497
577
  emit(:task_started, started_data(task, profile), task: task)
498
578
 
499
579
  actor.drain!
500
- run_pipeline(task, profile, actor, resume_from)
580
+ run_pipeline(task, profile, actor, resume_from, timing)
501
581
  # SINGLE capture at the top of the fiber: a single place maps
502
582
  # error -> terminal state -> events. Stages do no rescue of their own
503
583
  # (except tool, RubyLLM semantics). The fiber NEVER re-raises.
@@ -510,8 +590,25 @@ module Insika
510
590
  rescue PolicyDenied => e
511
591
  emit(:policy_denied, { policy: e.policy, reason: e.reason }, task: task)
512
592
  fail_task(task, e, stage: :policy)
593
+ rescue BudgetExceeded => e
594
+ # WS2 hard budget: a typed, retryable failure — the envelope reads
595
+ # budget_exceeded + retry_after (window roll), never a silent drop.
596
+ fail_task(task, e, stage: :budget)
597
+ rescue CircuitOpenError => e
598
+ # WS3 breaker: the turn died BEFORE the provider call — the envelope
599
+ # reads circuit_open + retry_after (cooldown remaining). Worth its own
600
+ # stage: an open breaker is a reliability decision, not an error bug.
601
+ fail_task(task, e, stage: :reliability)
602
+ rescue Insika::RoutingError => e
603
+ # WS4: a route's delegate is missing or its turn failed — an operator
604
+ # config error, staged so the envelope names routing, never :unknown.
605
+ fail_task(task, e, stage: :routing)
606
+ rescue Insika::MediaError => e
607
+ # WS9: a voice message that could not be fetched/transcribed (or a media
608
+ # URL the egress guard refused) — heard-loud, never a silent drop.
609
+ fail_task(task, e, stage: :media)
513
610
  rescue Insika::WorkflowSchemaError => e
514
- # Item 22 / §4.4: a workflow OUTPUT that violates its output_schema. Distinct
611
+ # a workflow OUTPUT that violates its output_schema. Distinct
515
612
  # stage so a contract breach is not conflated with an :unknown failure. (INPUT
516
613
  # is validated synchronously in TriggerWorkflow -> 422, never reaches here.)
517
614
  fail_task(task, e, stage: :workflow_schema)
@@ -526,7 +623,15 @@ module Insika
526
623
  rescue TimeoutError => e
527
624
  fail_task(task, e, stage: e.stage)
528
625
  rescue StandardError => e
529
- fail_task(task, e, stage: :unknown)
626
+ # A provider/transport failure is NOT an :unknown bug: wrap it with its
627
+ # action classification (B9) so the envelope can quote retryable and the
628
+ # provider's own retry_after (A8). The classifier is class-name based —
629
+ # the :ruby_llm stage stays reachable even under the smoke-shim's fake.
630
+ if ProviderErrorClassifier.provider_error?(e)
631
+ fail_task(task, ProviderErrorClassifier.wrap(e), stage: :ruby_llm)
632
+ else
633
+ fail_task(task, e, stage: :unknown)
634
+ end
530
635
  ensure
531
636
  @running.delete(task.id) # ALWAYS deregister (a false-positive running? would break the resume)
532
637
  # Deregistered FIRST on purpose: from here on the `steer` door finds no actor for
@@ -535,9 +640,39 @@ module Insika
535
640
  release_steered(task, profile, actor)
536
641
  end
537
642
 
643
+ # WS9 (saída), the RUNNER side the generate_image/tts system tools call
644
+ # (public like run_subagent — a tool reaches back into the Executor):
645
+ #
646
+ # -> [part, usage]: resolve the seam (injected or the lazy default) and
647
+ # run it. The default seams are built on FIRST use, when the turn already
648
+ # has a chat (ruby_llm loaded), so the load-guard holds.
649
+ def generate_media_output(kind, content, config)
650
+ seam = @media_output&.fetch(kind, nil) || Insika::Media::Output.defaults(context: @llm)[kind]
651
+ raise Insika::MediaError, "no #{kind} output seam" unless seam
652
+
653
+ seam.call(content, config)
654
+ end
655
+
656
+ # Accounts a generated part in the turn's usage: the provider's token
657
+ # counts (images — the merge keeps what the classifier already banked),
658
+ # plus an honest `media` call counter per part (the speech API reports no
659
+ # tokens; the part itself carries the model for consumer-side pricing).
660
+ def account_media_usage(state, part, usage)
661
+ usage ||= {}
662
+ tokens = {}
663
+ tokens[:input_tokens] = usage[:input_tokens].to_i if usage[:input_tokens]
664
+ tokens[:output_tokens] = usage[:output_tokens].to_i if usage[:output_tokens]
665
+ tokens[:total_tokens] = tokens[:input_tokens].to_i + tokens[:output_tokens].to_i if tokens.any?
666
+ unless tokens.empty?
667
+ tokens[:model] = part["model"] if part["model"]
668
+ state.usage = merge_usage(tokens, state.usage)
669
+ end
670
+ state.usage = (state.usage || {}).merge(media: state.usage&.fetch(:media, 0).to_i + 1)
671
+ end
672
+
538
673
  private
539
674
 
540
- # RFC-0015 §4 — resolves session vars > profile.limits > settings["queue"] >
675
+ # resolves session vars > profile.limits > settings["queue"] >
541
676
  # defaults. One session read plus one settings read per message, the same
542
677
  # order of cost the EdgeLimiter already pays per turn. A missing session (or
543
678
  # no session store) simply drops the vars layer.
@@ -558,7 +693,7 @@ module Insika
558
693
  SessionActor.new(session_id: session_id, executor: self, parent: turn_parent)
559
694
  end
560
695
 
561
- # RFC-0016 A3 — a turn that arrived while the process is draining: left
696
+ # a turn that arrived while the process is draining: left
562
697
  # `:queued` on purpose (recovery replays :queued at the next boot). The event
563
698
  # is the deferral's only trace — without it, "why was my message answered
564
699
  # only after the deploy" is unanswerable.
@@ -597,9 +732,23 @@ module Insika
597
732
  # 2.42): blocks on a dequeue that never arrives. Ends only when the scope is
598
733
  # stopped (server shutdown) — then any child turns still running go with it.
599
734
  # 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
735
+ # drains first, so only what outlives the drain deadline dies
601
736
  # here, `:running`, for the next boot's recovery to replay.
602
737
  @supervisor = node.async { |t| t.annotate("harness-turn-supervisor"); Async::Queue.new.dequeue }
738
+ # the periodic tick is a child of the supervisor — it binds to
739
+ # the serving reactor in every arm with no arm edits, and dies with the
740
+ # supervisor at shutdown (after Shutdown's drain, like any turn).
741
+ @tick&.start(parent: @supervisor)
742
+ # the alert dispatcher (WS6) lives on the same supervisor: its consumer
743
+ # answers every alert event for as long as the process serves.
744
+ @alert_dispatcher&.start(parent: @supervisor)
745
+ # the distillation engine lives on the same supervisor —
746
+ # its worker fiber re-scans idle sessions off the turn path.
747
+ @distill_engine&.start(parent: @supervisor)
748
+ # the harvest engine lives on the same supervisor — its
749
+ # worker fiber re-scans idle sessions off the turn path.
750
+ @harvest_engine&.start(parent: @supervisor)
751
+ @supervisor
603
752
  end
604
753
 
605
754
  # Deterministic PendingAction id: correlation by task+turn+tool.
@@ -608,7 +757,7 @@ module Insika
608
757
  # checkpointing is a future slice. One call per tool is safe.
609
758
  def pending_id(task_id, turn, tool) = "#{task_id}:#{turn}:#{tool}"
610
759
 
611
- # §11 B2: all readings of the persisted command go through rebuild_command
760
+ # all readings of the persisted command go through rebuild_command
612
761
  # (the single normalizer). command_type stays a STRING (the :task_started
613
762
  # event and the Telemetry attribute are string-typed).
614
763
  def command_type(task)
@@ -641,10 +790,17 @@ module Insika
641
790
  return nil
642
791
  end
643
792
 
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
793
+ # The task's error record and the :task_failed event both carry the
794
+ # classification when the failure is a wrapped ProviderError (B9/A8):
795
+ # additive fields, absent for every other error.
796
+ classification = error.respond_to?(:classification) ? error.classification : {}
797
+ spec = { class: error.class.name, message: error.message, stage: stage }
798
+ spec = spec.merge(classification) unless classification.empty?
799
+ @task_store.transition(task.id, to: :failed, error: spec)
800
+ data = { task_id: task.id, error: error.class.name, message: error.message }
801
+ data = data.merge(classification) unless classification.empty?
802
+ emit(:task_failed, data, task: task)
803
+ # a FAILED delegation child still delivers — the parent
648
804
  # receives an error note as a new turn (never left hanging).
649
805
  finalize_delegation(task)
650
806
  nil
@@ -656,8 +812,23 @@ module Insika
656
812
  # Stages 2-9, with mailbox drain only at the boundaries and the
657
813
  # turn-timeout wrapping everything via Async::Task#with_timeout — NEVER
658
814
  # stdlib Timeout.timeout.
659
- def run_pipeline(task, profile, actor, resume_from)
660
- timing = TurnTiming.new if TurnTiming.enabled?
815
+ def run_pipeline(task, profile, actor, resume_from, timing = nil)
816
+ # a CHANNEL turn always allocates the clock — first_balloon_ms
817
+ # (inbound -> first outbox flush) is H-latência and must not depend on
818
+ # INSIKA_TURN_TIMING. When the flag is off the clock measures ONLY that
819
+ # window (`breakdown: false`); the full prep/ttft/gen/total stays opt-in.
820
+ #
821
+ # A channel turn may already carry its clock: `SendMessage` stamped
822
+ # `:inbound` at 202 acceptance (before the debounce window and the
823
+ # SessionActor FIFO), so first_balloon_ms includes the wait the customer
824
+ # actually feels. A turn that reached here without one (boot resume,
825
+ # engine-initiated) falls back to allocating and stamps now — `mark` is
826
+ # first-write-wins, so a threaded clock is never re-stamped.
827
+ channel_turn = !channel_transport(task).nil?
828
+ timing ||= if TurnTiming.enabled? || channel_turn
829
+ TurnTiming.new(breakdown: TurnTiming.enabled?)
830
+ end
831
+ timing&.mark(:inbound) if channel_turn
661
832
  timing&.mark(:prep_start)
662
833
  state = build_turn_state(task, profile, resume_from)
663
834
  turn_timeout = turn_timeout_for(profile)
@@ -682,12 +853,12 @@ module Insika
682
853
  end
683
854
 
684
855
  # A Middleware short-circuited (did not call the terminal). Three cases:
685
- # · halt_response set -> GRACEFUL halt (RFC-0009 §3.1): the turn COMPLETES
856
+ # · halt_response set -> GRACEFUL halt: the turn COMPLETES
686
857
  # with a safe reply, reusing stages 8-9, without ever touching the LLM.
687
858
  # · halt_reason set -> halt-as-FAILURE (the pre-existing contract).
688
859
  # · neither -> contract violation (short-circuit with no signal).
689
860
  if !terminal_ran && state.halt_response
690
- complete_with_halt(task, profile, state)
861
+ complete_with_halt(task, profile, state, timing)
691
862
  elsif state.halt_reason
692
863
  raise Insika::Error, "turn halted: #{state.halt_reason}"
693
864
  elsif !terminal_ran
@@ -695,26 +866,41 @@ module Insika
695
866
  end
696
867
 
697
868
  state # subject of the :task pair (after_task receives it; the caller discards)
698
- end.tap { |st| emit_guardrail_flags(task, st) }
869
+ end.tap do |st|
870
+ # flush the evidence ledger HERE — after the after_task
871
+ # hooks ran, because the :flag validator increments the ungrounded
872
+ # counter in after_task. The envelope's ids (stage 6) ride the same
873
+ # flush. AFTER persist_turn (already done), so a flush failure can
874
+ # never un-commit the turn (the ledger swallows store errors).
875
+ st.evidence_ledger&.flush!
876
+ emit_guardrail_flags(task, st)
877
+ end
699
878
  end
700
879
  rescue Async::TimeoutError
701
880
  raise Insika::TimeoutError.new("turn exceeded #{turn_timeout}s", stage: :turn)
702
881
  end
703
882
 
704
- # Builds the turn's mutable state (turn number, message, memory tenant, D2
883
+ # Builds the turn's mutable state (turn number, message, memory tenant,
705
884
  # turn context). before_task (hooks.around) may still rewrite it before stage 2.
706
885
  def build_turn_state(task, profile, resume_from)
707
886
  turn = resume_from ? resume_from.turn : 1
708
887
  state = TurnState.new(task: task, profile: profile, turn: turn,
709
888
  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)
889
+ state.tenant = memory_tenant(task) # WRITE-path memory scope (`remember`); =chat
890
+ stamp_customer_session(task, profile)
891
+ state.turn_context = build_turn_context(task, profile, state) # data-tools' ctx.*
712
892
  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
893
+ # resolved for the RUN, not per message, so what the turn accepts cannot
714
894
  # change under it. Same cost as the EdgeLimiter's per-turn resolution. Only for a
715
895
  # SESSION turn: steering needs a session to arrive through, and resolving here for a
716
896
  # one-shot would make an unrelated turn fail on a queue key it can never use.
717
897
  state.queue_policy = task.session_id ? queue_policy(profile, task.session_id) : nil
898
+ # the session evidence ledger, built per turn. A nil
899
+ # session_id (one-shot) is fine — the ledger just never flushes (grounding
900
+ # on a one-shot is per-turn by definition). The envelope appends ids, the
901
+ # validator/enforcer read the union, stage 8 flushes.
902
+ state.evidence_ledger = Insika::EvidenceLedger.new(store: @session_store,
903
+ session_id: task.session_id)
718
904
  state
719
905
  end
720
906
 
@@ -732,6 +918,17 @@ module Insika
732
918
  # -> tool assembly, each followed by a mailbox drain at the boundary. Mutates
733
919
  # `state` in place (the TurnState possibly rewritten by before_task).
734
920
  def prepare_turn(task, profile, state, actor, resume_from)
921
+ # WS9 (saída): the CHANNEL's declared output media kinds — read on EVERY
922
+ # run (including resumes — it is plain config) so the ChatBuilder gate
923
+ # can re-wire the media tools; the transcription below stays guarded.
924
+ state.channel_capabilities = Insika::Media.channel_capabilities(
925
+ rebuild_command(task).payload["channel"]
926
+ )
927
+ # WS9: content parts -> turn. Runs BEFORE stage 2's context build so the
928
+ # transcribed voice text feeds the prompt; a resumed turn was already
929
+ # transcribed (never re-pay the STT call).
930
+ run_media_stage(task, state) unless state.resumed
931
+
735
932
  # stage 2: Context. The :prompt hook pair is wrapped INSIDE the
736
933
  # ContextBuilder#call — do NOT wrap here (a double-wrap would fire the hooks
737
934
  # twice). Hooks is the SAME instance injected into the Builder and here.
@@ -763,41 +960,252 @@ module Insika
763
960
  state.requires_approval = resolution.requires_approval
764
961
  state.allowed_tools = wrap_tools(assemble_tool_instances(resolution.allowed_tools, state), state, skip)
765
962
  state.allowed_skills = resolution.allowed_skills
963
+ record_context_trace(task, state)
964
+ announce_context_skills(task, state)
766
965
  drain_and_maybe_suspend(task, actor)
767
966
  end
768
967
 
968
+ # one entry per turn in the ContextTraceStore — tokens per
969
+ # category (the demodulized provider id), the tools-schema estimate and the
970
+ # budget verdict. the entry also carries the prefix
971
+ # fingerprints + the invalidation_reason vs the previous turn, and the
972
+ # categories gain their cache layer. Counts and ids ONLY, never content.
973
+ # nil store = off; the store itself rescues everything (the trace never
974
+ # breaks the turn). -> the sanitized entry (parked on TurnState for the
975
+ # stage-8 cache stamp).
976
+ def record_context_trace(task, state)
977
+ return unless @context_trace_store && task.session_id
978
+
979
+ package = state.context
980
+ # A custom builder that does not produce the full package (fragments +
981
+ # budget) simply has no breakdown to record — never an error.
982
+ return unless package.respond_to?(:fragments) && package.respond_to?(:budget)
983
+
984
+ categories = package.fragments.each_with_object({}) do |f, acc|
985
+ c = (acc[context_category(f.source)] ||= { tokens: 0, fragments: 0, pinned: 0 })
986
+ c[:tokens] += f.tokens || 0
987
+ c[:fragments] += 1
988
+ c[:pinned] += (f.tokens || 0) if f.pinned
989
+ # the category's cache layer (identity | :volatile —
990
+ # stamped by the Builder at production, C3).
991
+ layer = f.layer || :volatile
992
+ c[:layer] ||= layer
993
+ # WHICH skills/tools the fragment carried and WHY — ids only, still
994
+ # content-free. Without this the trace proves a turn injected N tokens of
995
+ # skill but not which ones, and deterministic activation is unauditable
996
+ # after the fact.
997
+ labels = Array(f.labels)
998
+ (c[:labels] ||= []).concat(labels) unless labels.empty?
999
+ end
1000
+ # the prefix chain over the SYSTEM-placement fragments in
1001
+ # canonical (identity-first) render order + the tool-schema serialization.
1002
+ # The reason is computed against the PREVIOUS trace entry (D3): the first
1003
+ # category, in current chain order, whose bytes changed (or vanished).
1004
+ previous = previous_trace_entry(task, state.turn)
1005
+ fingerprints = Insika::PrefixFingerprint.compute(
1006
+ Array(package.fragments).select { |f| f.placement == :system },
1007
+ tool_serial: serialize_tools(state.allowed_tools))
1008
+ reason = Insika::PrefixFingerprint.invalidation_reason(
1009
+ fingerprints, previous && previous["fingerprints"])
1010
+
1011
+ entry = { task_id: task.id, turn: state.turn, at: Time.now.utc.iso8601,
1012
+ cap: package.budget[:cap], used: package.budget[:used],
1013
+ evicted: package.budget[:evicted], categories: categories,
1014
+ tools: { count: state.allowed_tools.size,
1015
+ tokens: estimate_tools_tokens(state.allowed_tools) },
1016
+ fingerprints: fingerprints,
1017
+ cache: { invalidation_reason: reason } }
1018
+ # Park the SANITIZED entry (string keys) — the stage-8 stamp merges into
1019
+ # it and re-records the same key; a raw entry would add a SECOND "cache"
1020
+ # key that sanitize would then ignore (the symbol one wins).
1021
+ state.context_trace_entry = @context_trace_store.record(session_id: task.session_id,
1022
+ entry: entry)
1023
+ end
1024
+
1025
+ # the previous turn's trace entry — the session list minus
1026
+ # THIS (task_id, turn) (an approval-resumed turn re-records over its own key
1027
+ # — never compare to self). `turn` is the turn being recorded, passed
1028
+ # explicitly. -> Hash | nil (first turn of the session).
1029
+ def previous_trace_entry(task, turn)
1030
+ @context_trace_store.for_session(task.session_id)
1031
+ .reject { |x| x["task_id"] == task.id && x["turn"] == turn }
1032
+ .last
1033
+ end
1034
+
1035
+ # the tool-schema yardstick — the SAME serialization the token
1036
+ # estimate uses (estimate_tools_tokens), so the fingerprint and the estimate
1037
+ # never disagree. The digest covers name + description + parameters.inspect,
1038
+ # approximating RubyLLM's rendering (honest in the doc: the reason's job is
1039
+ # the CONTEXT categories; the tool hash is a guard rail).
1040
+ def serialize_tools(tools)
1041
+ tools.map { |t| "#{t.name} #{t.description} #{t.parameters.inspect}" }.join(" ")
1042
+ end
1043
+
1044
+ # the stage-8 stamp — the usage (cached_tokens,
1045
+ # input_tokens) only exists after the provider answered, so a SECOND
1046
+ # UPSERT with the SAME (task_id, turn) merges the cache fields into the
1047
+ # entry parked at prepare_turn (the start-of-turn write stays: a turn that
1048
+ # dies mid-flight still shows its context on the Studio screen). The same
1049
+ # numbers append one entry to the agent's CacheSeriesStore (C6).
1050
+ def stamp_cache_hit(task, state)
1051
+ usage = state.usage || {}
1052
+ input = usage[:input_tokens].to_i
1053
+ cached = usage[:cached_tokens].to_i
1054
+ creation = usage[:cache_creation_tokens].to_i
1055
+ # A4: the billed prefix is input + cached + cache_creation. RubyLLM's
1056
+ # input_tokens is the FRESH input only — cached_tokens is disjoint, not a
1057
+ # subset — so dividing by input alone yields absurd numbers (22000/500 =
1058
+ # 4400%) and renders a full hit as "—" (fresh=0). The denominator is the
1059
+ # whole billed prompt; hit_pct is then always in [0,100].
1060
+ billed = input + cached + creation
1061
+ hit = billed.positive? ? ((cached * 100.0) / billed).round : nil
1062
+ reason = state.context_trace_entry&.dig("cache", "invalidation_reason")
1063
+
1064
+ # The trace merge needs the entry parked at prepare_turn (the UPSERT
1065
+ # replaces the same (task_id, turn)); a failed trace write leaves it nil
1066
+ # and the cache line simply never lands — never a turn failure.
1067
+ if @context_trace_store && state.context_trace_entry && task.session_id
1068
+ @context_trace_store.record(
1069
+ session_id: task.session_id,
1070
+ entry: state.context_trace_entry.merge("cache" => {
1071
+ "hit_pct" => hit, "cached_tokens" => cached, "prompt_tokens" => billed,
1072
+ "invalidation_reason" => reason }))
1073
+ end
1074
+
1075
+ # The per-agent series is INDEPENDENT of the trace store: a deployment
1076
+ # that wires the series without the trace (or whose trace write failed)
1077
+ # still records its cache-hit numbers — reason is simply nil then.
1078
+ @cache_series_store&.record(agent: state.profile.id, entry: {
1079
+ at: Time.now.utc.iso8601, turn: state.turn,
1080
+ hit_pct: hit, cached_tokens: cached, prompt_tokens: billed,
1081
+ invalidation_reason: reason })
1082
+ end
1083
+
1084
+ # Skill bodies that reached the prompt WITHOUT a tool call (`triggers:` or
1085
+ # `skills_eager`) leave no trace in the transcript: there is no load_skill to
1086
+ # render, so an active skill looked exactly like an absent one.
1087
+ #
1088
+ # Emitted HERE rather than in the provider because only the Executor holds the
1089
+ # correlation the Studio's SSE filters on — an event whose meta lacks `task_id`
1090
+ # never reaches a task-scoped subscriber (EventStream::Subscription#matches?).
1091
+ # `skills` (plural, with reasons) marks the CONTEXT path; the load_skill tool
1092
+ # emits the same type with a singular `name`, and the Studio must not conflate them.
1093
+ #
1094
+ # Read from `package.fragments`, which is POST-BUDGET: a body the cut evicted is
1095
+ # not in the prompt, and announcing it as active would make the one surface built
1096
+ # to tell the truth the one that lies. Eviction is reported by the trace's own
1097
+ # `evicted` list, never as an activation.
1098
+ SKILL_BODY_CATEGORY = "skilltrigger"
1099
+
1100
+ def announce_context_skills(task, state)
1101
+ package = state.context
1102
+ return unless package.respond_to?(:fragments)
1103
+
1104
+ skills = Array(package.fragments)
1105
+ .select { |f| context_category(f.source) == SKILL_BODY_CATEGORY }
1106
+ .flat_map { |f| Array(f.labels) }
1107
+ .map { |l| { name: l["name"], reason: l["reason"] || "pack" } }
1108
+ .uniq
1109
+ return if skills.empty?
1110
+
1111
+ emit(:skill_activated, { skills: skills, source: "context" }, task: task)
1112
+ end
1113
+
1114
+ # "Insika::Context::Providers::Prompt" -> "prompt" (a plugin provider keeps
1115
+ # its own demodulized name — still content-free).
1116
+ def context_category(source) = source.to_s.split("::").last.to_s.downcase
1117
+
1118
+ # Same yardstick as the fragments (TokenEstimator), so the categories are
1119
+ # comparable. `parameters` is not guaranteed JSON-safe — inspect it.
1120
+ def estimate_tools_tokens(tools)
1121
+ TokenEstimator.estimate(tools.map { |t| "#{t.name} #{t.description} #{t.parameters.inspect}" }.join(" "))
1122
+ rescue StandardError
1123
+ 0
1124
+ end
1125
+
769
1126
  # Stages 5-9 (inside the Middleware wrap): assemble chat, the single agent
770
1127
  # interaction, persistence, terminal event. `st` is the Middleware-yielded state.
771
1128
  def run_turn_body(task, profile, st, actor, timing = nil)
772
1129
  # stage 5: assemble chat + check mailbox (send_message only; a workflow does
773
1130
  # not use the Insika chat — it orchestrates RubyLLM internally).
774
1131
  drain_and_maybe_suspend(task, actor)
775
- unless workflow_turn?(task)
1132
+ # WS4: intent routing, data-gated. Runs BEFORE the agent chat is assembled:
1133
+ # a route that delegates or ends :stuck completes the turn with no ask at
1134
+ # all (routed = true); a plain route is a label + event and the turn
1135
+ # proceeds. Skipped for workflows (no chat to route into) and resumed turns
1136
+ # (already admitted; re-classifying would re-pay the extra call).
1137
+ routed = !workflow_turn?(task) && !st.resumed ? attempt_route(task, profile, st) : false
1138
+ unless workflow_turn?(task) || routed
776
1139
  st.chat = create_chat(profile, st)
777
1140
  @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.
1141
+ # R1: baseline = seeded-history size, before `ask` appends the turn.
779
1142
  st.chat_baseline = Array(st.chat.messages).size if st.chat.respond_to?(:messages)
780
1143
  end
781
1144
 
782
- # guardrails (RFC-0009 §3.2): per-turn stream redactor (nil = off).
1145
+ # guardrails: per-turn stream redactor (nil = off).
783
1146
  st.output_filter = @content_filter_factory&.call(st)
784
1147
 
785
1148
  # stage 6: the turn's single agent interaction (send_message -> chat.ask;
786
- # trigger_workflow -> workflow.call). Returns the turn's final content.
787
- content = run_agent_stage(task, st, timing)
1149
+ # trigger_workflow -> workflow.call). A routed turn's content IS its route
1150
+ # action's answer (a delegate's reply or the stuck lead-in).
1151
+ content = routed ? st.response_content : run_agent_stage(task, st, timing)
788
1152
  st.response_content = content # after_task OutputValidator inspects this
789
1153
 
1154
+ # the model-visible record — what the provider received this
1155
+ # turn, captured at the boundary BEFORE stage 8 persists the checkpoint
1156
+ # (turn n's provider-visible stream == checkpoint(turn n+1).messages).
1157
+ # Skipped for workflows (they orchestrate RubyLLM inside the workflow
1158
+ # body — the engine cannot see those calls, stated in the conformance
1159
+ # scope) and absent-store runs (parity).
1160
+ record_model_visible(task, st) if !workflow_turn?(task) && st.chat
1161
+
1162
+ # the :enforce boundary — a CUT of the final content BEFORE
1163
+ # persistence/delivery (after_task fires too late to change what is
1164
+ # persisted). The cut text is what persists, delivers and terminates.
1165
+ if @grounding_enforcer
1166
+ content, st = @grounding_enforcer.call(task, st, content)
1167
+ st.response_content = content
1168
+ end
1169
+
790
1170
  # stage 8: Persistence (fixed order checkpoint->session->task). pure drain!
791
1171
  # (NEVER suspends at stage 8 — forbidden window): a :pause here arms the flag
792
1172
  # but is not honored (last stage); :cancel here still raises.
793
1173
  actor.drain!
794
- persist_turn(task, profile, st, content)
1174
+ persist_turn(task, profile, st, content, timing: timing)
1175
+
1176
+ # the cache-hit stamp — the usage exists only now. The
1177
+ # stamped entry is durable before anything is delivered (same slot as the
1178
+ # enforcer). Best-effort by construction (both stores rescue).
1179
+ stamp_cache_hit(task, st)
795
1180
 
796
1181
  # stage 9: Response. usage (tokens) captured at stage 6 travels in the
797
1182
  # terminal event -> /v1/responses usage + Telemetry (OTEL).
798
1183
  timing&.mark(:done)
799
1184
  data = { task_id: task.id, content: content, usage: st.usage }
1185
+ # WS4: the intent route rides the terminal additively (like outcome) — a
1186
+ # consumer aggregating by route does not need the stream.
1187
+ data[:route] = st.route.to_s if st.route
1188
+ # WS9: a turn whose message came from a VOICE note is marked — the
1189
+ # consumer's signal the person spoke (text was transcribed).
1190
+ data[:source] = :voice if st.message_source == :voice
1191
+ # WS9 (saída): media the agent GENERATED this turn (image/audio clips).
1192
+ # Additive sibling — the answer text stays text on purpose; the channel
1193
+ # consumes the parts next to it. Absent when nothing was generated.
1194
+ data[:output_parts] = st.output_parts if st.output_parts && !st.output_parts.empty?
800
1195
  data[:timing] = timing.to_h if timing # opt-in TTFB breakdown (INSIKA_TURN_TIMING)
1196
+ # best-effort persist of the same timing onto the task record —
1197
+ # the Studio task page reads it from there. A failure here must not re-fail
1198
+ # the turn (the task is already committed and the event already carries it).
1199
+ persist_turn_timing(task, timing)
1200
+ # WS5: the agent declared it cannot proceed (signal_stuck). The turn still
1201
+ # COMPLETES (its final message was published) — but the consumer must be able
1202
+ # to act on that, so the contract carries it twice: a dedicated :turn_stuck
1203
+ # event (subscribable) and an additive `outcome` sibling on the terminal event.
1204
+ if (stuck = st.stuck_outcome)
1205
+ emit(:turn_stuck, { task_id: task.id, agent: profile.id.to_s,
1206
+ reason: stuck[:reason], message: content }, task: task)
1207
+ data[:outcome] = :stuck
1208
+ end
801
1209
  emit(:task_completed, data, task: task)
802
1210
  end
803
1211
 
@@ -805,13 +1213,30 @@ module Insika
805
1213
  command_type(task).to_s == "trigger_workflow"
806
1214
  end
807
1215
 
1216
+ # the model-visible record of ONE ask — the chat at the
1217
+ # provider boundary (instructions + tool schemas + the message stream),
1218
+ # persisted under the checkpoint's turn number (turn n's stream ==
1219
+ # checkpoint(turn n+1).messages). Best-effort: the store rescues
1220
+ # everything, and the absent-store path is parity. `chat` defaults to the
1221
+ # turn's own chat; the routing classifier passes its own.
1222
+ def record_model_visible(task, st, chat = nil, part: "turn")
1223
+ return unless @model_visible_trace_store
1224
+
1225
+ c = chat || st.chat
1226
+ return unless c
1227
+
1228
+ @model_visible_trace_store.record(
1229
+ task_id: task.id, turn: st.turn + 1, part: part,
1230
+ payload: Insika::ModelVisible.capture(c))
1231
+ end
1232
+
808
1233
  # Stage 6: the single agent interaction. Returns the turn's final content.
809
1234
  def run_agent_stage(task, state, timing = nil)
810
1235
  if workflow_turn?(task)
811
1236
  # workflow = a Ruby callable that orchestrates RubyLLM internally (RubyLLM
812
1237
  # First). tools: are the SAME instances filtered by the Resolution and
813
1238
  # 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
1239
+ # the EXPOSED surface — the run (== task.id) is announced on
815
1240
  # the stream (:workflow_started), the RETURN is validated against the
816
1241
  # output_schema (WorkflowSchemaError -> :workflow_schema stage), and the
817
1242
  # typed output is published (:workflow_completed).
@@ -826,43 +1251,37 @@ module Insika
826
1251
  emit(:workflow_completed, { run_id: task.id, workflow: definition.name, output: output }, task: task)
827
1252
  output
828
1253
  else
829
- filter = state.output_filter # RFC-0009 §3.2: nil = off (stream untouched)
1254
+ filter = state.output_filter # nil = off (stream untouched)
830
1255
  timing&.mark(:ask)
831
1256
  # TurnOutput owns what the customer is allowed to read: chunks ride
832
1257
  # :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".
1258
+ # :content. Registered on the chat (fresh per turn/attempt, no leak).
1259
+ # With WS3 reliability the attempts build their own chats + outputs.
1260
+ output = nil
845
1261
  asked = nil
846
1262
  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
1263
+ result = @reliability ? run_reliable_ask(task, s, filter, timing)
1264
+ : run_single_ask(task, s, filter, timing)
1265
+ output = result[:output]
1266
+ asked = result[:asked]
1267
+ result[:response]
855
1268
  end
856
1269
  # release the redactor's retained tail (a value that never completed into a
857
1270
  # match is emitted redacted-if-needed, not lost) before reading anything back.
858
1271
  output.flush
859
- state.usage = with_model_source(usage_of(response), state.model_selection) unless halted?(response)
1272
+ # MERGED, not overwritten: a WS4 routing call already banked its tokens in
1273
+ # state.usage before the ask — the classifier's cost must survive the ask
1274
+ # (it feeds the EdgeLimiter's ceiling/budget and the terminal usage).
1275
+ unless halted?(response)
1276
+ state.usage = merge_usage(with_model_source(usage_of(response), state.model_selection),
1277
+ state.usage)
1278
+ end
860
1279
 
861
1280
  # BOUNDARY BEFORE THE ANSWER GOES OUT. A cancel that arrived while the provider
862
1281
  # was working used to be observed at stage 8 — AFTER `:content` had already been
863
1282
  # published — so the customer read the answer of a turn that then terminated
864
1283
  # `: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
1284
+ # Honoring it here is what makes `interrupt` mean anything, and it
866
1285
  # is a safe boundary: the tool batch is finished and nothing is half applied.
867
1286
  # A `:pause` is deliberately NOT honored here (drain!, not the suspending form):
868
1287
  # holding a completed answer for an operator would strand it unpublished.
@@ -872,7 +1291,400 @@ module Insika
872
1291
  end
873
1292
  end
874
1293
 
875
- # RFC-0015 §5.2 — wires the tool-batch boundary that lets a message which arrived
1294
+ # stage 6, plain path: the single ask on the assembled chat. Fresh TurnOutput
1295
+ # + steer wiring per interaction (registered on state.chat, which the solve
1296
+ # already assembled). -> { response:, asked:, output: }.
1297
+ def run_single_ask(task, state, filter, timing)
1298
+ output = new_turn_output(task, state, filter)
1299
+ wire_chat_output(task, state, output)
1300
+ asked = ask_on(task, state, state.chat, output, timing)
1301
+ { response: asked, asked: asked, output: output }
1302
+ end
1303
+
1304
+ # stage 6, WS3 path: the Reliability coordinator drives retries, backoff,
1305
+ # circuit breaker and the fallback rotation. Each ATTEMPT gets a fresh chat
1306
+ # + output (a failed `ask` leaves its message in the chat, so re-asking the
1307
+ # same one would double the input) and, on a fallback, state.model_selection
1308
+ # follows — the turn's usage is attributed to the model that actually spoke
1309
+ # ("contabilizado no trace"). -> { response:, asked:, output: }.
1310
+ def run_reliable_ask(task, state, filter, timing)
1311
+ policy = state.profile.respond_to?(:reliability) ? state.profile.reliability : nil
1312
+ return run_single_ask(task, state, filter, timing) if policy.nil? || @reliability.nil?
1313
+
1314
+ attempt_output = nil
1315
+ attempt_asked = nil
1316
+ primary = state.model_selection
1317
+ response = @reliability.call(
1318
+ policy: policy, tenant: task_tenant(task), agent: state.profile.id.to_s,
1319
+ selection: primary, chain: reliability_chain(state)
1320
+ ) do |selection, tries|
1321
+ first_attempt = state.chat && selection == primary && tries == 1
1322
+ if selection != primary
1323
+ state.model_selection = selection # attribution follows the fallback
1324
+ end
1325
+ unless first_attempt
1326
+ chat = build_attempt_chat(state, selection)
1327
+ state.chat = chat
1328
+ end
1329
+ attempt_output = new_turn_output(task, state, filter)
1330
+ wire_chat_output(task, state, attempt_output)
1331
+ attempt_asked = ask_on(task, state, state.chat, attempt_output, timing)
1332
+ attempt_asked
1333
+ end
1334
+ { response: response, asked: attempt_asked, output: attempt_output }
1335
+ end
1336
+
1337
+ # The fallback chain for WS3: profile's `reliability["fallback"]` refs first,
1338
+ # then the platform-resolved fallbacks (ModelSelection). Each node is a
1339
+ # ModelSelection (the SAME duck the primary is — usage attribution and
1340
+ # apply_params just work), source: :fallback, params inherited from the
1341
+ # primary. Deduped by ref, primary excluded.
1342
+ def reliability_chain(state)
1343
+ primary = state.model_selection
1344
+ refs = Array((state.profile.reliability || {})["fallback"]).map(&:to_s)
1345
+ nodes = refs.filter_map { |r| parse_model_ref(r) }.reject { |n| n[:model].to_s.empty? }
1346
+ nodes.concat(Array(primary.fallbacks).map { |f| { model: f[:model], provider: f[:provider] } })
1347
+ seen = { ref_of(primary) => true }
1348
+ nodes.filter_map do |node|
1349
+ ref = model_ref(node)
1350
+ # normalize "model" vs "provider/model": a provider-less ref IS the same
1351
+ # physical model as any known "provider/model" spelling of it — the same
1352
+ # model must never be tried twice just because one spelling omits the
1353
+ # provider (WS3: fallback ["deepseek-v4-flash"] under primary
1354
+ # deepseek/deepseek-v4-flash used to re-ask the dropped primary). A
1355
+ # qualified ref still matches exactly.
1356
+ duplicate = ref.include?("/") ? seen[ref]
1357
+ : seen.keys.any? { |known| known.split("/").last == ref }
1358
+ next if duplicate
1359
+
1360
+ seen[ref] = true
1361
+ Insika::ModelSelection.new(model: node[:model], provider: node[:provider],
1362
+ source: :fallback, params: primary.params, fallbacks: [])
1363
+ end
1364
+ end
1365
+
1366
+ def ref_of(selection) = model_ref(selection)
1367
+
1368
+ # --- WS9 media ------------------------------------------------------
1369
+ #
1370
+ # Content parts on the command -> a turn: audio parts are transcribed (the
1371
+ # text enters the message marked `source: :voice` — the consumer's signal
1372
+ # the person SPOKE), image parts become the ask's attachments (the model
1373
+ # sees them; the provider bills them — usage flows) and the first URL is
1374
+ # deposited as `ctx.image_url` for data/HTTP tools. The engine transports
1375
+ # media, never meaning: no speech/vision logic beyond the call itself.
1376
+ def run_media_stage(task, state)
1377
+ # a consumer that pre-transcribed voice text labels it `source: voice`;
1378
+ # the marker rides the turn even when there are no audio PARTS left.
1379
+ state.message_source = :voice if rebuild_command(task).payload["source"].to_s == "voice"
1380
+
1381
+ parts = Insika::Media.parts(rebuild_command(task).payload["parts"])
1382
+ return if parts.empty?
1383
+
1384
+ voice = Insika::Media.audio_parts(parts)
1385
+ if voice.any?
1386
+ text = voice.map { |p| media_transcribe(p.url) }.reject(&:empty?).join(" ")
1387
+ state.message = [state.message.to_s, text].reject(&:empty?).join("\n")
1388
+ state.message_source = :voice
1389
+ end
1390
+
1391
+ images = Insika::Media.image_parts(parts)
1392
+ if images.any?
1393
+ state.media_attachments = images.map { |p| media_attachment(p.url) }
1394
+ # First image URL for data tools (`{{ctx.image_url}}`) — photo analysis
1395
+ # outside the prompt. The model still sees the attachment; the tool
1396
+ # gets the original URL (its own egress applies when it fetches).
1397
+ state.turn_context = (state.turn_context || {}).merge(image_url: images.first.url)
1398
+ end
1399
+
1400
+ # A media-only turn (a voice note with no caption) is legitimate — the
1401
+ # surfaces admit it — but it must leave this stage with something to ask
1402
+ # about. Empty text AND no attachment means the parts carried nothing the
1403
+ # engine could use (a transcription that came back blank): fail loudly at
1404
+ # :media rather than ask the provider about nothing.
1405
+ return unless state.message.to_s.strip.empty? && state.media_attachments.nil?
1406
+
1407
+ raise Insika::MediaError, "the message parts produced no text and no attachment"
1408
+ end
1409
+
1410
+ # The STT seam: the injected transcriber (specs), else the default
1411
+ # (fetch + RubyLLM::Transcription — lazy require). A failed transcription
1412
+ # fails the turn loudly (MediaError -> :media): a voice message that was
1413
+ # not heard must not become a hallucinated one.
1414
+ def media_transcribe(url)
1415
+ transcriber = @media || (@default_transcriber ||= Insika::Media.default_transcriber(
1416
+ stt_model: Insika::EnvSchema.read("INSIKA_STT_MODEL"),
1417
+ stt_language: Insika::EnvSchema.read("INSIKA_STT_LANGUAGE")
1418
+ ))
1419
+ transcriber.call(url)
1420
+ end
1421
+
1422
+ # An image part -> the ask's attachment. RubyLLM required lazily (load-guard).
1423
+ #
1424
+ # The bytes come through OUR fetch (`Media.fetch_binary`), which is
1425
+ # egress-guarded — the URL is CONSUMER input, so a private/metadata target
1426
+ # fails the turn loudly at :media — and SIZE-CAPPED. Handing the raw URL to
1427
+ # `RubyLLM::Attachment` instead left the fetch to the gem, whose
1428
+ # `fetch_content` reads the whole response with no ceiling: a hostile URL
1429
+ # answering an endless body grows this process until it dies. An io-like
1430
+ # source (StringIO) is the branch of Attachment that takes bytes we already
1431
+ # hold; the provider then gets base64 rather than the URL, which every
1432
+ # vision provider accepts.
1433
+ def media_attachment(url)
1434
+ require "ruby_llm"
1435
+ require "stringio"
1436
+
1437
+ bytes = Insika::Media.fetch_binary(url, max_bytes: Insika::Media::MAX_IMAGE_BYTES)
1438
+ RubyLLM::Attachment.new(StringIO.new(bytes), filename: media_filename(url))
1439
+ end
1440
+
1441
+ # The URL's basename, for the attachment's mime sniff (".png" -> image/png;
1442
+ # a URL with no filename falls back to the content sniff RubyLLM does).
1443
+ def media_filename(url)
1444
+ name = File.basename(URI.parse(url).path.to_s)
1445
+ name.empty? ? nil : name
1446
+ rescue URI::InvalidURIError
1447
+ nil
1448
+ end
1449
+
1450
+ # --- WS4 intent routing --------------------------------------------
1451
+ #
1452
+ # The turn's message is classified into one of the profile's routes with a
1453
+ # CHEAP model (data-gated: no `routes` on the profile = byte-identical turn).
1454
+ # -> true when the route took over the turn (delegated / stuck — no ask
1455
+ # happens); false when the turn proceeds normally. Classification happens on
1456
+ # a fresh chat carrying ONLY the auto-generated route prompt (no identity,
1457
+ # no tools — it is a router, not the agent); its tokens ride the turn's
1458
+ # usage, so the trace, the token ceiling and the budget all see the cost.
1459
+ def attempt_route(task, profile, state)
1460
+ meta = Insika::Routing.normalize(profile.routes)
1461
+ return false unless meta
1462
+ return false unless route_model(meta, profile)
1463
+
1464
+ selection = route_selection(meta, profile)
1465
+ classification = classify_route(selection, meta, state.message, task, state)
1466
+ return false if classification.nil? # the classifier call failed — routing is additive
1467
+
1468
+ route = classification[:route]
1469
+ state.route = route
1470
+ # MERGED, not assigned: a WS9 transcription may already have banked its
1471
+ # tokens in state.usage (the classifier call must not erase them).
1472
+ state.usage = merge_usage(with_model_source(usage_of(classification[:response]), selection),
1473
+ state.usage)
1474
+ emit(:route_classified,
1475
+ { task_id: task.id, agent: profile.id.to_s, route: route.to_s,
1476
+ model: selection.model, usage: state.usage },
1477
+ task: task)
1478
+
1479
+ entry = meta[:entries].find { |e| e.name == route.to_s }
1480
+ apply_route_action(task, profile, state, entry)
1481
+ end
1482
+
1483
+ # The classifier call, its answer parsed back into a route.
1484
+ # -> { route:, response: } | nil (nil = the call failed — the turn proceeds
1485
+ # unrouted rather than paying a wrong label or dying for an additive step).
1486
+ def classify_route(selection, meta, message, task, st)
1487
+ response = route_ask(selection, Insika::Routing.classifier_prompt(meta), message, task, st)
1488
+ { route: Insika::Routing.parse(route_response_text(response), meta), response: response }
1489
+ rescue StandardError
1490
+ nil
1491
+ end
1492
+
1493
+ # The cheap classifier's model: routes["model"] wins, the agent's own model
1494
+ # otherwise. No model anywhere = no routing.
1495
+ def route_model(meta, profile)
1496
+ ref = meta[:model].to_s
1497
+ ref = profile.model.to_s if ref.empty?
1498
+ !ref.empty?
1499
+ end
1500
+
1501
+ def route_selection(meta, profile)
1502
+ ref = meta[:model].to_s
1503
+ ref = profile.model.to_s if ref.empty?
1504
+ parsed = parse_model_ref(ref) || {}
1505
+ provider = parsed[:provider].nil? ? profile.provider : parsed[:provider].to_sym
1506
+ Insika::ModelSelection.new(model: parsed[:model], provider: provider, source: :routing)
1507
+ end
1508
+
1509
+ # A fresh chat for the routing model with ONLY the generated prompt. RubyLLM
1510
+ # required lazily, exactly like create_chat (the load-guard holds).
1511
+ # the classifier is model-visible, so it is logged — the ONE
1512
+ # engine-internal ask the conformance spec adds a record for (part
1513
+ # "routing", same turn number as the answer ask).
1514
+ def route_ask(selection, prompt, message, task, st)
1515
+ require "ruby_llm"
1516
+ chat = (@llm || RubyLLM).chat(model: selection.model, provider: selection.provider,
1517
+ assume_model_exists: selection.assume_model_exists?)
1518
+ chat.with_instructions(prompt) if chat.respond_to?(:with_instructions)
1519
+ response = chat.ask(message.to_s)
1520
+ record_model_visible(task, st, chat, part: "routing")
1521
+ response
1522
+ end
1523
+
1524
+ def route_response_text(response)
1525
+ response.respond_to?(:content) ? response.content.to_s : response.to_s
1526
+ end
1527
+
1528
+ # The route's config decides the turn's fate: nothing (a label), a DELEGATE
1529
+ # (an existing agent answers; its reply IS the turn's), or STUCK (WS5 — the
1530
+ # turn ends with the stuck outcome; the consumer interprets it).
1531
+ def apply_route_action(task, profile, state, entry)
1532
+ return false unless entry
1533
+
1534
+ if entry.delegate && !entry.delegate.empty?
1535
+ delegate_route(profile, state, entry.delegate)
1536
+ true
1537
+ elsif entry.stuck
1538
+ message = entry.message.empty? ? entry.description : entry.message
1539
+ state.stuck_outcome = { reason: "route:#{state.route}", message: message }
1540
+ state.response_content = message
1541
+ true
1542
+ else
1543
+ false
1544
+ end
1545
+ end
1546
+
1547
+ # WS4 delegate action: the route names an existing agent — the turn is
1548
+ # handed to it (the sync subagent machinery) and the child's answer IS the
1549
+ # parent's answer. A missing agent or a failed child fails the turn: never
1550
+ # fabricate the customer's reply.
1551
+ #
1552
+ # The depth comes from the PARENT's turn context, +1, and is capped here —
1553
+ # this path does not go through `plan_subagent` (a route has no subagents
1554
+ # allowlist to check against), so a hardcoded depth of 1 made an A -> B -> A
1555
+ # route pair a loop with no floor: every hop reclassifies (a paid ask) and
1556
+ # spawns another child, forever.
1557
+ def delegate_route(profile, state, agent_id)
1558
+ child = @profiles[agent_id.to_s]
1559
+ raise Insika::RoutingError, "route delegate agent '#{agent_id}' not configured" if child.nil?
1560
+
1561
+ depth = (state.turn_context&.dig(:delegation_depth) || 0) + 1
1562
+ cap = SubagentGraph.depth_cap
1563
+ if depth > cap
1564
+ raise Insika::RoutingError,
1565
+ "routed delegate '#{agent_id}' at depth #{depth} exceeds cap #{cap}"
1566
+ end
1567
+
1568
+ result = spawn_and_await_child(child, state.message, depth, state)
1569
+ if result[:error]
1570
+ raise Insika::RoutingError, "routed delegate '#{agent_id}' failed: #{result[:error]}"
1571
+ end
1572
+
1573
+ state.response_content = result[:text].to_s
1574
+ end
1575
+
1576
+ # The WS4 classifier's tokens, summed over the ask's (a call that reported no
1577
+ # usage contributes nothing). The turn's own model/source win for attribution
1578
+ # — the routing model's identity lives on the :route_classified event.
1579
+ def merge_usage(main, extra)
1580
+ return main || extra if main.nil? || extra.nil?
1581
+
1582
+ Insika::Routing::TOKEN_FIELDS.each_with_object(main.dup) do |k, acc|
1583
+ next if extra[k].nil?
1584
+ next unless main.key?(k) || extra[k].to_i.positive?
1585
+
1586
+ acc[k] = main[k].to_i + extra[k].to_i
1587
+ end
1588
+ end
1589
+
1590
+ # "provider/model" for any selection duck (ModelSelection | { model:, provider: }).
1591
+ def model_ref(selection)
1592
+ model = selection.respond_to?(:model) ? selection.model.to_s : selection[:model].to_s
1593
+ provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
1594
+ provider ? "#{provider}/#{model}" : model
1595
+ end
1596
+
1597
+ # "provider/model" -> { model:, provider: }; "model" -> { model:, provider: nil }.
1598
+ def parse_model_ref(entry)
1599
+ s = entry.to_s.strip
1600
+ return nil if s.empty?
1601
+
1602
+ if s.include?("/")
1603
+ provider, model = s.split("/", 2)
1604
+ { model: model, provider: presence_or_nil(provider)&.to_sym }
1605
+ else
1606
+ { model: s, provider: nil }
1607
+ end
1608
+ end
1609
+
1610
+ def presence_or_nil(value)
1611
+ v = value.to_s.strip
1612
+ v.empty? ? nil : v
1613
+ end
1614
+
1615
+ # A fresh chat for a retry/fallback attempt: REASSEMBLED from the same turn
1616
+ # state (the seed history is identical), baseline reset -> the transcript
1617
+ # recorded from than point is the attempt that spoke.
1618
+ def build_attempt_chat(state, selection)
1619
+ chat = build_chat(selection, state.model_selection)
1620
+ @chat_builder.assemble(chat, state, emit: ->(type, data) { emit(type, data, task: state.task) })
1621
+ state.chat_baseline = Array(chat.messages).size if chat.respond_to?(:messages)
1622
+ chat
1623
+ end
1624
+
1625
+ def new_turn_output(task, state, filter)
1626
+ TurnOutput.new(filter: filter, emit: ->(type, data) { emit(type, data, task: task) },
1627
+ public_intermediate: state.profile.stream_public?(:intermediate))
1628
+ end
1629
+
1630
+ # The message-boundary + steer wiring ON the current chat. Registered
1631
+ # AFTER TurnOutput so the publishing decision for a message is made before
1632
+ # anything is appended after it — the gem's callbacks are additive and run
1633
+ # in registration order.
1634
+ def wire_chat_output(task, state, output)
1635
+ chat = state.chat
1636
+ chat.after_message { |message| output.message_ended(message) } if chat.respond_to?(:after_message)
1637
+ install_steer_injector(task, state)
1638
+ end
1639
+
1640
+ # The ask itself, chunk-by-chunk (WS3 attempts and the plain path share it).
1641
+ # With INSIKA_TURN_TIMING the FIRST content chunk also emits the live
1642
+ # :ttft event — the streaming envelope's TTFB signal (WS6), additive. The
1643
+ # emit is gated to that first chunk: a probe proved the old code re-emitted
1644
+ # :ttft on EVERY content chunk (3 chunks = 3 insika.ttft frames); the spec
1645
+ # passed because FakeChat emits a single chunk.
1646
+ def ask_on(task, state, chat, output, timing)
1647
+ public_thinking = state.profile.stream_public?(:thinking)
1648
+ ttft_sent = false
1649
+ each_chunk = lambda do |chunk|
1650
+ emit_thinking(chunk, task, public: public_thinking)
1651
+ next unless chunk.content
1652
+
1653
+ timing&.mark(:first_token) # first-write-wins -> the PROVIDER's TTFB
1654
+ unless ttft_sent
1655
+ emit_ttft(task, timing) if timing
1656
+ ttft_sent = true
1657
+ end
1658
+ output.push(chunk.content)
1659
+ end
1660
+ # WS9: image parts ride the ask as attachments (only then — a chat whose
1661
+ # ask has no `with:` keeps working, and the plain path is byte-identical).
1662
+ # An image with no caption asks with NIL, not "": an empty text part is a
1663
+ # thing some providers refuse, and nil is how RubyLLM says "attachments
1664
+ # only".
1665
+ if state.media_attachments
1666
+ text = state.message.to_s.empty? ? nil : state.message
1667
+ chat.ask(text, with: state.media_attachments, &each_chunk)
1668
+ else
1669
+ chat.ask(state.message, &each_chunk)
1670
+ end
1671
+ end
1672
+
1673
+ # The provider's TTFB as a live event (data: ttft_ms) — only under
1674
+ # INSIKA_TURN_TIMING, so absent by default (parity). Rides the SINGLE
1675
+ # emitter: a hand-built meta here lacked `tenant`, and a tenant-scoped
1676
+ # /v1/events subscription is fail-closed on it — the tenant's own TTFB was
1677
+ # invisible to the tenant.
1678
+ def emit_ttft(task, timing)
1679
+ ttft = timing.to_h[:ttft_ms]
1680
+ return if ttft.nil?
1681
+
1682
+ emit(:ttft, { ttft_ms: ttft }, task: task)
1683
+ rescue StandardError
1684
+ nil
1685
+ end
1686
+
1687
+ # wires the tool-batch boundary that lets a message which arrived
876
1688
  # mid-run enter the conversation. No-op unless the agent asked for `steer`: an
877
1689
  # unregistered callback is the difference between a feature that is off and one that
878
1690
  # is on and finds nothing.
@@ -948,9 +1760,9 @@ module Insika
948
1760
  #
949
1761
  # Three deliberate omissions:
950
1762
  # · the guardrail filter is NOT applied — it accumulates the PERSISTED content
951
- # (D3), and pushing reasoning through it would corrupt the turn's answer;
1763
+ # and pushing reasoning through it would corrupt the turn's answer;
952
1764
  # · `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
1765
+ # PROVIDER's first token ('s baselines measure that, not the first
954
1766
  # thought, and not when TurnOutput publishes the answer);
955
1767
  # · nothing is persisted — the reasoning is not part of the conversation.
956
1768
  #
@@ -967,7 +1779,7 @@ module Insika
967
1779
  emit(:thinking, data, task: task)
968
1780
  end
969
1781
 
970
- # Annotates the usage with the RESOLVED model-selection source (v2, §10):
1782
+ # Annotates the usage with the RESOLVED model-selection source:
971
1783
  # where the model came from (:chat/:agent/:platform_default) travels alongside
972
1784
  # the resolved model id (from the provider) into the terminal event/Telemetry,
973
1785
  # so billing/telemetry can attribute the turn to a config layer. nil usage
@@ -994,7 +1806,7 @@ module Insika
994
1806
  if response.respond_to?(:cached_tokens) && response.cached_tokens
995
1807
  usage[:cached_tokens] = response.cached_tokens.to_i # cache_read_input_tokens
996
1808
  end
997
- # §11 R3: prompt-cache WRITE tokens (Anthropic cache_creation_input_tokens),
1809
+ # R3: prompt-cache WRITE tokens (Anthropic cache_creation_input_tokens),
998
1810
  # billed at ~1.25x. Reported so the first (write) turn vs later (read) turns
999
1811
  # are distinguishable in telemetry/usage.
1000
1812
  if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens
@@ -1022,7 +1834,7 @@ module Insika
1022
1834
 
1023
1835
  def build_context_request(task, profile, state, resume_from)
1024
1836
  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)
1837
+ state.session = session # create_chat reads it for the per-chat model pin
1026
1838
  hist = command_history(task)
1027
1839
  # `vars` reconciles the seam (the Request/Session provider already
1028
1840
  # called request.vars): session metadata + the explicit `history` in the
@@ -1031,12 +1843,14 @@ module Insika
1031
1843
  vars["history"] = hist if hist
1032
1844
  # The single type is Insika::ContextRequest (Data); the explicit `history`
1033
1845
  # travels in vars["history"] (Session provider convention), not in a field
1034
- # of its own.
1846
+ # of its own. `memory_scope` is the WS8 customer cell (nil = the providers
1847
+ # fall back to tenant || session, today's behavior).
1035
1848
  ContextRequest.new(profile: profile, message: state.message, session: session,
1036
- checkpoint: resume_from, tenant: command_tenant(task), vars: vars)
1849
+ checkpoint: resume_from, tenant: command_tenant(task), vars: vars,
1850
+ memory_scope: memory_tenant(task))
1037
1851
  end
1038
1852
 
1039
- # :task_started payload. Carries the EXPLICIT command tenant (item 16 / P4) so
1853
+ # task_started payload. Carries the EXPLICIT command tenant so
1040
1854
  # the observability convention can group by it — the one operator-set label that
1041
1855
  # is not derivable from the task itself. Omitted when absent: the terminal
1042
1856
  # events keep their shape and no consumer sees a null it never saw before. NOT
@@ -1062,44 +1876,96 @@ module Insika
1062
1876
  Coercion.presence(rebuild_command(task).payload["origin"])
1063
1877
  end
1064
1878
 
1065
- # Engine memory scope (D3): the Command's EXPLICIT tenant wins (multi-merchant
1879
+ # Engine memory scope: the Command's EXPLICIT tenant wins (multi-merchant
1066
1880
  # override); otherwise the SESSION (=chat) — engine-owner memory is per-chat.
1067
1881
  # Symmetric to the READ path (Memory provider). One-shot with no tenant -> nil
1068
1882
  # (_default). It is NOT the <request_context> tenant (that follows
1069
1883
  # command_tenant, prompt parity) — only the memory read/write scope.
1884
+ #
1885
+ # WS8: a request carrying a CUSTOMER moves the scope to the customer cell —
1886
+ # "[tenant:]customer" when a tenant is present, the bare customer otherwise
1887
+ # (never _default — a tagged customer must never land in the shared cell).
1888
+ # Per-customer memory is the 360 view; per-tenant was the leak.
1889
+ #
1890
+ # the SESSION fallback is MARKED ("chat:<session id>" -> cell
1891
+ # "memory:chat:<session id>"), never a bare cell — a bare "memory:<id>" is
1892
+ # indistinguishable from a single-tenant customer ref, and the Studio drill
1893
+ # must not list conversations as customers with a Forget button.
1070
1894
  def memory_tenant(task)
1071
- command_tenant(task) || task.session_id
1895
+ customer = command_customer(task)
1896
+ return command_tenant(task) || session_scope(task.session_id) if customer.nil?
1897
+
1898
+ [command_tenant(task), customer].compact.join(":")
1899
+ end
1900
+
1901
+ # The marked per-session scope : "chat:<session id>" -> cell
1902
+ # "memory:chat:<session id>". nil for a one-shot turn (no session) — the
1903
+ # MemoryStore applies _default.
1904
+ def session_scope(session_id)
1905
+ return nil if session_id.nil?
1906
+
1907
+ "#{MemoryStore::SESSION_TAG}:#{session_id}"
1908
+ end
1909
+
1910
+ # WS8 + : stamp the customer (WS8 — the `forget_customer`
1911
+ # purge finds the customer's sessions through this var) AND the agent (the
1912
+ # distillation engine resolves each session's pack through it) on the
1913
+ # session ONCE (idempotent). A session that does not exist yet (no
1914
+ # session_id on the turn) is skipped; a look-up failure never breaks the
1915
+ # turn.
1916
+ def stamp_customer_session(task, profile)
1917
+ customer = command_customer(task)
1918
+ return if customer.nil? || task.session_id.nil?
1919
+
1920
+ session = @session_store&.find(task.session_id)
1921
+ return if session.nil? || !Coercion.presence(session.vars["customer"]).nil?
1922
+
1923
+ @session_store.update_vars(task.session_id,
1924
+ "customer" => customer, "agent" => profile.id)
1925
+ rescue Insika::NotFoundError, ArgumentError
1926
+ nil
1927
+ end
1928
+
1929
+ # The optional customer_key on the command payload (WS8): a String identifying
1930
+ # the person the conversation belongs to — the memory scope's customer half
1931
+ # and the handle `forget_customer` purges by. nil = untagged conversation
1932
+ # (memory stays per-tenant/per-chat, byte-identical to before).
1933
+ def command_customer(task)
1934
+ Coercion.presence(rebuild_command(task).payload["customer"])
1072
1935
  end
1073
1936
 
1074
- # Turn context (Phase 6/D2/G4): the ids the data-tools resolve via
1937
+ # Turn context: the ids the data-tools resolve via
1075
1938
  # {{ctx.*}} to emit X-Chat-Id/X-Store-Id/X-Agent-Id to /api/internal/*. They
1076
1939
  # come from the TURN, never from the model args (R2). chat_id = the session
1077
1940
  # (the /v1/responses adapter creates the session with id = user = chat.id);
1078
1941
  # tenant = the Command tenant (memory) OR chat_id (drop-in default); agent_id =
1079
1942
  # profile; store_id = the profile metadata (stable per store, from the pack).
1080
1943
  # 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).
1944
+ # profile carries store_id). Generic: nothing here mentions a consumer.
1082
1945
  def build_turn_context(task, profile, state)
1083
1946
  {
1084
1947
  chat_id: task.session_id,
1085
1948
  agent_id: profile.id,
1086
- tenant: state.tenant, # already = command_tenant || session_id (memory_tenant)
1949
+ # the DATA-TOOL header tenant stays the merchant (or the chat), even when
1950
+ # the memory scope carries a customer — the backend identifies the store,
1951
+ # not the shopper (WS8 keeps the two scopes separate).
1952
+ tenant: command_tenant(task) || task.session_id,
1087
1953
  store_id: profile.store_id,
1088
- # RFC-0010: current delegation depth (0 for a top-level turn). Carried in
1954
+ # current delegation depth (0 for a top-level turn). Carried in
1089
1955
  # the child command's payload by run_subagent; read here so the child's OWN
1090
1956
  # spawn_subagent tool sees depth+1 and the runtime cap holds down the chain.
1091
1957
  delegation_depth: delegation_depth(task)
1092
1958
  }
1093
1959
  end
1094
1960
 
1095
- # Delegation depth of THIS turn (RFC-0010): the value run_subagent stamped in
1961
+ # Delegation depth of THIS turn: the value run_subagent stamped in
1096
1962
  # the child command, or 0 for a top-level turn. Integer-coerced (JSON round-trip
1097
1963
  # of the persisted command may deliver a String).
1098
1964
  def delegation_depth(task)
1099
1965
  rebuild_command(task).payload["delegation_depth"].to_i
1100
1966
  end
1101
1967
 
1102
- # RFC-0010 R2: environment (model/thinking) inherits as DEFAULT — the child's
1968
+ # R2: environment (model/thinking) inherits as DEFAULT — the child's
1103
1969
  # explicit value wins; when absent, seed from the parent's RESOLVED selection.
1104
1970
  # Capacity fields are untouched (R1: the child profile is used as-is). Returns
1105
1971
  # the child profile unchanged when there is nothing to inherit.
@@ -1120,7 +1986,7 @@ module Insika
1120
1986
  child_profile.with(model: model, provider: provider, params: params)
1121
1987
  end
1122
1988
 
1123
- # Single validation path for a delegation (RFC-0010) — shared by run_subagent
1989
+ # Single validation path for a delegation — shared by run_subagent
1124
1990
  # and the fan-out run_subagents. `task` is {agent, message} (string OR symbol
1125
1991
  # keys — the model's args arrive string-keyed). Returns a resolved plan
1126
1992
  # { agent:, profile:, message:, depth: } or { agent:, error: } (the agent name is
@@ -1193,7 +2059,7 @@ module Insika
1193
2059
  [child_session_id, child_task]
1194
2060
  end
1195
2061
 
1196
- # SYNC (Fase 1): spawns the child and AWAITS it on the parent's fiber, then
2062
+ # SYNC: spawns the child and AWAITS it on the parent's fiber, then
1197
2063
  # projects the terminal content. Direct `spawn` (not spawn_in_session): the
1198
2064
  # child session is brand-new, so there is no SessionActor contention — the child
1199
2065
  # is parented at turn_parent and the parent yields cooperatively on `wait`.
@@ -1204,7 +2070,7 @@ module Insika
1204
2070
  project_child_result(child_task.id, child_session_id, child_profile.id, parent_state)
1205
2071
  end
1206
2072
 
1207
- # ASYNC (Fase 2): persists a Delegation, spawns the child NON-blocking, and
2073
+ # ASYNC: persists a Delegation, spawns the child NON-blocking, and
1208
2074
  # returns a dispatch ack immediately — the parent turn ends without waiting. The
1209
2075
  # child's terminal hook (finalize_delegation) delivers the result later, as a
1210
2076
  # NEW turn on the parent session.
@@ -1253,7 +2119,7 @@ module Insika
1253
2119
  exec.error && (exec.error["message"] || exec.error[:message])
1254
2120
  end
1255
2121
 
1256
- # RFC-0010 Fase 2 — terminal hook: when a turn ends (success OR failure), if the
2122
+ # terminal hook: when a turn ends (success OR failure), if the
1257
2123
  # task is the child of an ASYNC delegation, capture its result and deliver it to
1258
2124
  # the parent. Fires for both a normal completion and a resumed one (recovery),
1259
2125
  # so it needs no live watcher fiber. No-op without a delegation_store or when the
@@ -1337,12 +2203,14 @@ module Insika
1337
2203
  command: rebuild_command(task),
1338
2204
  context: state.context,
1339
2205
  candidate_tools: @tool_registry.entries,
1340
- candidate_skills: @skill_catalog.effective(profile.skills)
2206
+ # agent: so a specialized skill reaches the policy as the agent's own version
2207
+ # (same name, its body) instead of the shared one it overrides.
2208
+ candidate_skills: @skill_catalog.effective(profile.skills, agent: profile.id)
1341
2209
  )
1342
2210
  end
1343
2211
 
1344
2212
  # 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
2213
+ # a Command with #type (Symbol) and #payload.: the SINGLE point that
1346
2214
  # reconciles the string||symbol keys of the persisted command — payload/meta
1347
2215
  # keys are stringified ONCE here, so every reader (command_type/workflow_name/
1348
2216
  # extract_message/command_history/command_tenant) works with string keys.
@@ -1362,7 +2230,7 @@ module Insika
1362
2230
  (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
1363
2231
  end
1364
2232
 
1365
- # Stage-3-tail tool assembly — delegated to ToolAssembly (§11 B5). Kept as
2233
+ # Stage-3-tail tool assembly — delegated to ToolAssembly. Kept as
1366
2234
  # thin private methods so the existing spec contract (executor.send(:...))
1367
2235
  # stays intact and run_pipeline reads unchanged.
1368
2236
  def resolve_capabilities(profile, context) = @tool_assembly.resolve_capabilities(profile, context)
@@ -1403,13 +2271,13 @@ module Insika
1403
2271
  ))
1404
2272
  end
1405
2273
 
1406
- # GRACEFUL halt (RFC-0009 §3.1): a Middleware short-circuited with a safe reply.
2274
+ # GRACEFUL halt: a Middleware short-circuited with a safe reply.
1407
2275
  # The turn COMPLETES — same stages 8-9 as a normal turn — but the "assistant
1408
2276
  # content" is the guardrail's safe response, produced with ZERO LLM calls. The
1409
2277
  # order mirrors a real turn so both the /v1/responses consumer (which reads the
1410
2278
  # text off :content deltas) and the Studio viewer render it: audit -> safe text
1411
2279
  # -> persist -> terminal.
1412
- def complete_with_halt(task, profile, state)
2280
+ def complete_with_halt(task, profile, state, timing = nil)
1413
2281
  content = state.halt_response.to_s
1414
2282
  state.response_content = content
1415
2283
 
@@ -1420,39 +2288,60 @@ module Insika
1420
2288
  }, task: task)
1421
2289
  end
1422
2290
  emit(:content, { delta: content }, task: task) unless content.empty?
1423
- # An EDGE-blocked turn (rate limit / token ceiling, item 33) completes but
2291
+ # An EDGE-blocked turn (rate limit / token ceiling) completes but
1424
2292
  # stays OUT of the session history: a flood at the wall must not bloat the
1425
2293
  # session nor evict real conversation from the context budget — the
1426
2294
  # :guardrail_blocked event is the audit trail. Content-guardrail blocks
1427
- # keep persisting (RFC-0009: the refusal is part of the conversation).
2295
+ # keep persisting (the refusal is part of the conversation).
1428
2296
  # The reply is the guardrail's, produced with zero LLM calls — so it is NOT the
1429
2297
  # agent talking, and a report that counts it as the agent repeating itself is
1430
2298
  # reading the engine's own canned text (the `safe_reply` finding exists exactly
1431
2299
  # because that text is otherwise indistinguishable in the transcript).
1432
2300
  persist_turn(task, profile, state, content, reply_origin: MessageOrigin::ENGINE,
1433
- session: state.guardrail_block&.[](:source) != "edge")
1434
- emit(:task_completed, { task_id: task.id, content: content, usage: state.usage }, task: task)
2301
+ session: state.guardrail_block&.[](:source) != "edge", timing: timing)
2302
+ data = { task_id: task.id, content: content, usage: state.usage }
2303
+ data[:timing] = timing.to_h if timing # a channel halt still measured
2304
+ persist_turn_timing(task, timing)
2305
+ emit(:task_completed, data, task: task)
1435
2306
  end
1436
2307
 
1437
- # Emits one :guardrail_flagged per flag the OutputValidator appended in
1438
- # after_task (audit only — the turn already completed). Reads a plain Array off
1439
- # the state, keeping the Executor decoupled from Safety.
1440
- def emit_guardrail_flags(task, state)
1441
- return unless state.respond_to?(:guardrail_flags)
2308
+ # Best-effort write of the turn's timing onto the task record .
2309
+ # The record gains `timing` once, when the turn completes; a store failure
2310
+ # here is swallowed — the turn is already committed and the event already
2311
+ # carries the number.
2312
+ def persist_turn_timing(task, timing)
2313
+ return unless timing
1442
2314
 
1443
- Array(state.guardrail_flags).each do |flag|
1444
- emit(:guardrail_flagged, {
1445
- task_id: task.id, category: flag[:category], source: flag[:source], detail: flag[:detail]
1446
- }, task: task)
1447
- end
2315
+ hash = timing.to_h
2316
+ return if hash.empty?
2317
+
2318
+ @task_store.record_timing(task.id, hash)
2319
+ rescue Insika::Error
2320
+ nil
1448
2321
  end
1449
2322
 
2323
+ # Emits one :guardrail_flagged per flag the OutputValidator appended in
2324
+ # after_task (audit only — the turn already completed). Reads a plain Array off
2325
+ # the state, keeping the Executor decoupled from Safety. an
2326
+ # :enforce cut rides the SAME event with `action: "cut"` so the audit can
2327
+ # distinguish a cut from a flag.
2328
+ def emit_guardrail_flags(task, state)
2329
+ return unless state.respond_to?(:guardrail_flags)
2330
+
2331
+ Array(state.guardrail_flags).each do |flag|
2332
+ data = { task_id: task.id, category: flag[:category], source: flag[:source],
2333
+ detail: flag[:detail] }
2334
+ data[:action] = flag[:action] if flag[:action]
2335
+ emit(:guardrail_flagged, data, task: task)
2336
+ end
2337
+ end
2338
+
1450
2339
  # Stage 8: FIXED order checkpoint -> session -> task. If it crashes
1451
2340
  # between writes, the worst case is a new checkpoint with the task :running ->
1452
2341
  # Recovery re-executes the already-saved turn (safe thanks to the side-effect
1453
2342
  # recording).
1454
2343
  #
1455
- # CHECKPOINT vs SESSION — the two stores DIVERGE by design (§11 R2c):
2344
+ # CHECKPOINT vs SESSION — the two stores DIVERGE by design (R2c):
1456
2345
  # · Checkpoint.messages = flatten_history(context.history) + new_messages,
1457
2346
  # i.e. "what the model actually SAW this turn" AFTER budget eviction
1458
2347
  # (context.history is the post-budget assembly). It is the deterministic
@@ -1463,7 +2352,7 @@ module Insika
1463
2352
  # So a long session legitimately has a Checkpoint SHORTER than the Session:
1464
2353
  # that is not drift to reconcile — it is the point. Do NOT "fix" the checkpoint
1465
2354
  # to carry the full history (it would defeat the budget) nor evict the session.
1466
- def persist_turn(task, profile, state, content, session: true, reply_origin: nil)
2355
+ def persist_turn(task, profile, state, content, session: true, reply_origin: nil, timing: nil)
1467
2356
  new_messages = turn_transcript(state, content, origin: command_origin(task), reply_origin: reply_origin)
1468
2357
  transcript = flatten_history(state.context.history) + new_messages
1469
2358
 
@@ -1493,22 +2382,22 @@ module Insika
1493
2382
 
1494
2383
  emit(:checkpoint_created, { task_id: task.id, turn: state.turn + 1 }, task: task)
1495
2384
 
1496
- # RFC-0010 Fase 2: if this completed turn is an ASYNC delegation child,
2385
+ # if this completed turn is an ASYNC delegation child,
1497
2386
  # deliver its result to the parent as a NEW turn. No-op for a normal turn
1498
2387
  # (not a delegation child) or without a delegation_store.
1499
2388
  finalize_delegation(task)
1500
2389
 
1501
- # RFC-0011 §6.5: if this turn CAME IN through a Shape B channel, its answer
2390
+ # if this turn CAME IN through a Shape B channel, its answer
1502
2391
  # has to travel out of band. Same terminal hook, next door to the delegation
1503
2392
  # one, for the same reason: it fires for a fresh turn and a recovered one.
1504
- finalize_channel_delivery(task, content)
2393
+ finalize_channel_delivery(task, content, state, timing)
1505
2394
  end
1506
2395
 
1507
2396
  # Records the answer in the outbox and dispatches it. The discriminator is the
1508
2397
  # turn's TRANSPORT (`channel:<id>` on the persisted command), not the session:
1509
2398
  # a session belongs to the channel forever, but a message an operator types into
1510
2399
  # 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
2400
+ # Human handoff is not a product feature (``), and it would be a
1512
2401
  # surprising way to acquire one.
1513
2402
  #
1514
2403
  # The consequence, stated rather than discovered later: a turn the ENGINE
@@ -1518,31 +2407,44 @@ module Insika
1518
2407
  #
1519
2408
  # Best-effort: the turn is already committed and durable, and a delivery problem
1520
2409
  # must never re-fail it.
1521
- def finalize_channel_delivery(task, content)
2410
+ #
2411
+ # a PROGRESSIVE channel gets the answer split into balloons —
2412
+ # N outbox rows, dispatched in index order (dispatch_chain). `:at_end` is the
2413
+ # single whole-answer row, byte-identical to today.
2414
+ def finalize_channel_delivery(task, content, state, timing = nil)
1522
2415
  return unless @channel_delivery
1523
2416
 
1524
2417
  channel_id = channel_transport(task)
1525
2418
  return unless channel_id
1526
2419
 
1527
- delivery = @channel_delivery.record(task: task, channel_id: channel_id, content: content)
1528
- return unless delivery
2420
+ # the hoarded evidence attachments ride the channel delivery
2421
+ # (additive outbox payload key — the channel contract widens, nothing breaks).
2422
+ attachments = state.respond_to?(:evidence_attachments) ? state.evidence_attachments : nil
2423
+ deliveries = @channel_delivery.record_balloons(
2424
+ task: task, channel_id: channel_id, content: content,
2425
+ progressive: @channel_delivery.progressive?(channel_id),
2426
+ attachments: attachments
2427
+ )
2428
+ return if deliveries.empty?
1529
2429
 
1530
- dispatch_delivery(delivery.id)
2430
+ timing&.mark(:first_balloon) # C5: inbound -> first outbox row, first-write-wins
2431
+ dispatch_chain(deliveries.map(&:id))
1531
2432
  rescue Insika::Error
1532
2433
  nil
1533
2434
  end
1534
2435
 
1535
- # The POST goes out on the SUPERVISOR, never on the turn's fiber: a bounded
1536
- # retry against a third party would otherwise hold the session's FIFO — the
1537
- # customer's next message would wait on their previous answer's delivery.
1538
- # Non-serving (boot sweep, specs) delivers inline, where waiting is what the
1539
- # caller wants.
1540
- def dispatch_delivery(delivery_id)
1541
- return @channel_delivery.deliver(delivery_id) unless @supervised
2436
+ # ONE supervisor fiber for the whole chain. Sequential deliver
2437
+ # calls, so balloon N+1 cannot overtake balloon N on the wire. Still off the
2438
+ # session's FIFO — the customer's next message does not wait on this turn's
2439
+ # outbound. Non-serving (boot sweep, specs) delivers inline, where waiting is
2440
+ # what the caller wants.
2441
+ def dispatch_chain(ids)
2442
+ run = lambda { ids.each { |id| @channel_delivery.deliver(id) } }
2443
+ return run.call unless @supervised
1542
2444
 
1543
2445
  turn_parent.async do |t|
1544
- t.annotate("outbox:#{delivery_id}")
1545
- @channel_delivery.deliver(delivery_id)
2446
+ t.annotate("outbox:#{ids.first}")
2447
+ run.call
1546
2448
  end
1547
2449
  end
1548
2450
 
@@ -1553,15 +2455,15 @@ module Insika
1553
2455
  transport.start_with?("channel:") ? transport.delete_prefix("channel:") : nil
1554
2456
  end
1555
2457
 
1556
- # Truncation cap for a persisted `role: tool` content (§11 R1): the transcript
2458
+ # Truncation cap for a persisted `role: tool` content (R1): the transcript
1557
2459
  # keeps the loop coherent; the FULL result lives in the ToolTraceStore (viewer).
1558
2460
  TOOL_CONTENT_CAP = 4_000
1559
2461
 
1560
- # The turn's messages in the ADDITIVE string-keyed format (§11 R1). Prefers the
2462
+ # The turn's messages in the ADDITIVE string-keyed format (R1). Prefers the
1561
2463
  # real chat transcript (`chat.messages.drop(baseline)`) so tool calls/results
1562
2464
  # survive between turns; falls back to the {user, assistant} pair when the chat
1563
2465
  # 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),
2466
+ # The final assistant text is the REDACTED `content` (output_filter),
1565
2467
  # never the raw text the gem stored.
1566
2468
  # `origin` (MessageOrigin) travels on the turn's Command and is stamped on the
1567
2469
  # message it describes: the INCOMING one. It is absent for an ordinary turn, and
@@ -1637,7 +2539,7 @@ module Insika
1637
2539
  end
1638
2540
 
1639
2541
  # 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
2542
+ # grouped as one Array by the Session provider, R1). Checkpoints store a
1641
2543
  # FLAT list — the provider regroups on read. Flatten one level; message Hashes
1642
2544
  # are untouched.
1643
2545
  def flatten_history(history) = Array(history).flatten(1)
@@ -1653,28 +2555,58 @@ module Insika
1653
2555
  require_relative "tools/remember"
1654
2556
  require_relative "tools/subagent"
1655
2557
  require_relative "tools/subagents"
1656
- # v2 resolution (§10): Chat pin > Agent model > platform default, model_policy
2558
+ require_relative "tools/stuck_signal"
2559
+ require_relative "tools/generate_image"
2560
+ require_relative "tools/tts"
2561
+ require_relative "tools/update_briefing"
2562
+ # the schedule/cancel_followup builtins — lazy, same
2563
+ # boundary (the ChatBuilder wires them only when a profile declares
2564
+ # followup AND the stores are present).
2565
+ require_relative "tools/schedule_followup"
2566
+ # v2 resolution: Chat pin > Agent model > platform default, model_policy
1657
2567
  # enforced, fallback chain resolved. Kept on the state for telemetry (usage).
1658
2568
  selection = @model_resolver.resolve(profile: profile, session: state.session)
1659
2569
  state.model_selection = selection
2570
+ build_chat(selection, selection)
2571
+ end
2572
+
2573
+ # The gem boundary: one chat for a model selection (the resolved primary or
2574
+ # a WS3 fallback node). The primary's generation params apply to the whole
2575
+ # chain (params_source: ModelSelection#apply_params).
2576
+ def build_chat(selection, params_source)
2577
+ model = selection.respond_to?(:model) ? selection.model : selection[:model]
2578
+ provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
1660
2579
  chat = (@llm || RubyLLM).chat(
1661
- model: selection.model,
1662
- provider: selection.provider,
1663
- assume_model_exists: selection.assume_model_exists?
2580
+ model: model,
2581
+ provider: provider,
2582
+ assume_model_exists: !provider.nil?
1664
2583
  )
1665
- selection.apply_params(chat) # temperature/max_tokens/thinking (per-agent, §10)
2584
+ params_source.apply_params(chat) # temperature/max_tokens/thinking (per-agent)
1666
2585
  chat
1667
2586
  end
1668
2587
 
1669
2588
  # Single emitter: an Event with meta and a monotonic seq per task. @seqs is not
1670
2589
  # cleared at the end of the task — the resume (new Execution) continues the
1671
- # numbering (reliable replay).
2590
+ # numbering (reliable replay). A task WITH a tenant (WS1) tags every event it
2591
+ # emits — the tenant-scoped /v1/events subscription filters on it (a control
2592
+ # event without a task has no tenant and never matches a tenant stream);
2593
+ # absent tenant -> the meta is byte-identical to before.
1672
2594
  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
- ))
2595
+ meta = { task_id: task.id, session_id: task.session_id,
2596
+ seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601 }
2597
+ tenant = task_tenant(task)
2598
+ meta[:tenant] = tenant unless tenant.nil?
2599
+ @event_stream.emit(Insika::Event.new(type: type, data: data, meta: meta))
2600
+ end
2601
+
2602
+ # The tenant stamped on the task's command (WS1), nil when the request was
2603
+ # operator-made. Cheap read on the persisted command hash — never rebuilds.
2604
+ def task_tenant(task)
2605
+ command = task.respond_to?(:command) ? task.command : nil
2606
+ return nil unless command.is_a?(Hash)
2607
+
2608
+ meta = command["meta"] || command[:meta] || {}
2609
+ meta["tenant"] || meta[:tenant]
1678
2610
  end
1679
2611
  end
1680
2612
  end