insika 0.8.0 → 1.0.1

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -3,6 +3,7 @@
3
3
  require "time"
4
4
  require "securerandom"
5
5
  require "async/queue"
6
+ require_relative "telemetry/pricing"
6
7
 
7
8
  module Insika
8
9
  # Coordinates execution. It does not build context, decide policy, or talk to
@@ -18,12 +19,15 @@ module Insika
18
19
  session_store:, task_store:, checkpoint_store:,
19
20
  event_stream:, workflow_registry: nil, pending_action_store: nil,
20
21
  capability_registry: nil, tool_catalog: nil, memory_store: nil,
21
- tool_trace_store: nil, settings_store: nil, content_filter_factory: nil,
22
+ tool_trace_store: nil, llm_trace_store: nil, model_metrics_store: nil, settings_store: nil, content_filter_factory: nil,
22
23
  delegation_store: nil, channel_delivery: nil, llm: nil,
23
24
  context_trace_store: nil, reliability: nil, media: nil, media_output: nil,
24
25
  grounding_enforcer: nil, cache_series_store: nil,
25
26
  contact_store: nil, followup_store: nil, model_visible_trace_store: nil,
26
- knowledge_store: nil)
27
+ knowledge_store: nil, shared_conversations: nil, llm_refresh: nil, pricing: nil)
28
+ @pricing = pricing || Telemetry::Pricing.from_env
29
+ @shared_conversations = shared_conversations
30
+ @shared_turns = {}
27
31
  @context_builder = context_builder
28
32
  @policy_engine = policy_engine
29
33
  @middleware = middleware
@@ -39,6 +43,10 @@ module Insika
39
43
  @workflow_registry = workflow_registry # stage 6 of trigger_workflow
40
44
  @pending_action_store = pending_action_store # approval gate
41
45
  @capability_registry = capability_registry # capability resolution (nil = off)
46
+ @llm_trace_store = llm_trace_store
47
+ @model_metrics_store = model_metrics_store
48
+ # task id -> the running turn's LLM diagnostics, written once when it ends
49
+ @llm_buffers = {}
42
50
  @tool_trace_store = tool_trace_store # tool-call trace for Studio debugging (nil = off)
43
51
  # per-turn context breakdown (tokens by category + budget) for the
44
52
  # Studio session card. nil = off (no record, zero overhead — parity).
@@ -67,6 +75,10 @@ module Insika
67
75
  # process-wide RubyLLM constant (the historic single-graph deployment).
68
76
  # Duck-typed: Context#chat and RubyLLM.chat take the same keywords.
69
77
  @llm = llm
78
+ # ->() re-applies the LLM providers authored at runtime when they changed
79
+ # (LLMConfigurator#refresh), so a key stored through another worker reaches
80
+ # this one before its next chat. nil = credentials fixed at boot.
81
+ @llm_refresh = llm_refresh
70
82
  # stability for the turn's single agent interaction (WS3): retries /
71
83
  # fallback / circuit breaker, all DATA on AgentProfile#reliability.
72
84
  # nil = the plain single ask (parity).
@@ -184,6 +196,10 @@ module Insika
184
196
  # scans, nothing mines).
185
197
  attr_accessor :harvest_engine
186
198
 
199
+ # Insika::MemoryTrim: hands freed malloc memory back to the OS when the worker
200
+ # goes quiet (a child of the turn supervisor, like the tick). nil = off.
201
+ attr_accessor :memory_trim
202
+
187
203
  # Forces the long-lived turn supervisor (and its tick/alert/distill/harvest
188
204
  # children — see #turn_parent) to start NOW instead of lazily on the first
189
205
  # served turn. Without this, a deployment whose only agents are scheduled
@@ -255,6 +271,22 @@ module Insika
255
271
  !actor.nil?
256
272
  end
257
273
 
274
+ # RubyLLM may query this repeatedly while partitioning pending tools.
275
+ # Only the persisted, exactly matching operator decision authorizes a call.
276
+ def approval_decision(task:, turn:, tool:, args:, call_id:)
277
+ raise Insika::Error, "PendingActionStore is not configured" unless @pending_action_store
278
+ raise Insika::ValidationError, "approval requires a tool call id" if call_id.to_s.empty?
279
+
280
+ action = @pending_action_store.find(native_pending_id(task.id, turn, call_id))
281
+ return nil unless action
282
+
283
+ unless action.kind == PendingActionStore::OPERATOR && action.task_id == task.id.to_s &&
284
+ action.turn == turn && action.tool == tool.to_s && action.args == Coercion.deep_stringify(args || {})
285
+ raise Insika::ValidationError, "approval does not match the pending tool call"
286
+ end
287
+ { approved: true, rejected: false }[action.status]
288
+ end
289
+
258
290
  # Approval gate, called by the ToolEnvelope at stage 6 when the
259
291
  # tool requires approval. Creates/queries the PendingAction (deterministic id
260
292
  # by task+turn+tool — per-tool correlation as with the side-effect),
@@ -281,6 +313,7 @@ module Insika
281
313
  end
282
314
 
283
315
  @task_store.transition(task.id, to: :waiting) if @task_store.find(task.id).status == :running
316
+ flush_llm_diagnostics(task) # the wait may last hours
284
317
 
285
318
  # Awaits the resolution of THIS pending. A spurious :approval (duplicate or
286
319
  # from another pending of the same actor) that wakes up before resolution is
@@ -349,12 +382,25 @@ module Insika
349
382
  return nil unless @supervised && session_id
350
383
 
351
384
  policy = queue_policy(profile, session_id)
352
- return nil unless policy.collect? && policy.debounce?
385
+ # The MODE is the whole gate. `debounce_ms` used to be required here too,
386
+ # which made "the message arrived before the turn started" mean only "inside a
387
+ # window an operator configured": a burst landing on a turn already queued
388
+ # behind another joined nothing and became a third turn. A window still
389
+ # widens the door (it holds the turn at the front of the FIFO); it no longer
390
+ # is the door.
391
+ return nil unless policy.collect?
353
392
 
354
393
  actor = @session_actors[session_id]
355
394
  return nil unless actor&.alive?
356
395
 
357
- actor.collect(text)
396
+ # FRAMED, like the steer door does at `SteerInjector#absorb_pending!`. The two
397
+ # doors used to disagree: a steered message became its OWN `user` message in the
398
+ # transcript (so the model saw a separate ask) while a merged one was concatenated
399
+ # into the queued turn's single payload by a bare "\n" — five customer messages
400
+ # arrived as one run-on paragraph, and a trailing constraint got answered by
401
+ # nothing. `steer_join` is the knob that already exists for exactly this, and a
402
+ # nil one returns the text untouched, so a wiring that never set it is unchanged.
403
+ actor.collect(policy.frame(text))
358
404
  end
359
405
 
360
406
  # the `steer` door: a message for a session whose turn is ALREADY
@@ -376,8 +422,8 @@ module Insika
376
422
  return nil unless session_actor&.alive?
377
423
 
378
424
  # No turn running (or one still at the door): there is nothing to steer INTO.
379
- # A turn at the door is the collect door's other window — a steer agent with a
380
- # debounce merges there instead , so no message waits on either.
425
+ # A turn at the door is the collect door's business — a steer agent merges
426
+ # there instead (`collect?` is true for :steer), so no message waits on either.
381
427
  task = session_actor.current_task
382
428
  return nil if task.nil?
383
429
  # A workflow turn orchestrates RubyLLM itself and has no Insika chat to append to
@@ -503,10 +549,9 @@ module Insika
503
549
  fail_spawn(task, e)
504
550
  end
505
551
 
506
- # Shuts down all SessionActors (server shutdown / tests — the loop blocks
507
- # forever on dequeue when idle).
552
+ # Shuts down all SessionActors (server shutdown / tests).
508
553
  def stop_session_actors
509
- @session_actors.each_value(&:stop)
554
+ @session_actors.values.each(&:stop)
510
555
  @session_actors.clear
511
556
  nil
512
557
  end
@@ -587,76 +632,104 @@ module Insika
587
632
 
588
633
  # Stages 2..9. Runs INSIDE the task's fiber.
589
634
  def execute(task, profile:, actor:, resume_from: nil, timing: nil)
635
+ state = nil
636
+ @llm_buffers[task.id] = []
637
+ Fiber[TurnTiming::FIBER_KEY] = nil # a failure before run_pipeline has no clock of its own
590
638
  # Resume of a crash orphan: the interrupted attempt's Execution was left OPEN
591
639
  # (the fiber died). The TaskStore forbids opening a second one while one is
592
640
  # open -> close the orphan as :interrupted before opening the N+1 (a new
593
641
  # entry, never overwrites).
594
642
  close_orphan_execution(task) if resume_from
595
- @task_store.begin_execution(task.id) # attempt N+1
596
- # queued (normal spawn) and paused/waiting (resume) -> running. An orphan is
597
- # already :running (running->running is invalid) -> no transition.
598
- status = @task_store.find(task.id).status
599
- @task_store.transition(task.id, to: :running) if %i[queued paused waiting].include?(status)
643
+ # attempt N+1, and queued (normal spawn) or paused/waiting (resume) ->
644
+ # running, in one write. An orphan is already :running and stays so.
645
+ @task_store.start_execution(task.id)
600
646
  emit(:task_started, started_data(task, profile), task: task)
601
647
 
648
+ if profile.respond_to?(:shared_conversations) && profile.shared_conversations
649
+ @shared_turns[task.id] = { committed: false }
650
+ raise StoreError, "shared conversation service is not configured" unless @shared_conversations
651
+ @shared_turns[task.id][:memory] = (profile.memory || profile.knowledge) ? @shared_conversations.memory_context(task:task,profile:profile) : {}
652
+ @shared_turns[task.id][:history] = if resume_from
653
+ @shared_conversations.resume_turn(task: task, profile: profile, checkpoint: resume_from)
654
+ else
655
+ @shared_conversations.begin_turn(task: task, profile: profile)
656
+ end
657
+ end
602
658
  actor.drain!
603
- run_pipeline(task, profile, actor, resume_from, timing)
659
+ run_pipeline(task, profile, actor, resume_from, timing) { |current| state = current }
604
660
  # SINGLE capture at the top of the fiber: a single place maps
605
661
  # error -> terminal state -> events. Stages do no rescue of their own
606
662
  # (except tool, RubyLLM semantics). The fiber NEVER re-raises.
663
+ rescue SharedConversations::Recovered => recovered
664
+ @shared_turns[task.id][:committed] = true
665
+ @task_store.complete_execution(task.id, outcome: :completed)
666
+ emit(:content, {delta: recovered.content}, task: task)
667
+ recovery_state = TurnState.new(task: task, profile: profile, turn: 1, message: '')
668
+ recovery_state.evidence_attachments = recovered.attachments
669
+ finalize_channel_delivery(task, recovered.content, recovery_state, timing)
670
+ data = {task_id: task.id, content: recovered.content}
671
+ data[:output_parts] = recovered.output_parts unless recovered.output_parts.empty?
672
+ emit(:task_completed, data, task: task)
607
673
  rescue CancelledError
608
674
  # cancel is not an error: transition WITHOUT error: (does not close the
609
675
  # Execution), then finish_execution closes it with outcome :cancelled.
610
676
  @task_store.transition(task.id, to: :cancelled)
611
677
  @task_store.finish_execution(task.id, outcome: :cancelled)
612
- emit(:task_cancelled, { task_id: task.id }, task: task)
678
+ data = { task_id: task.id }
679
+ data[:usage] = state.usage if state&.usage
680
+ emit(:task_cancelled, data, task: task)
613
681
  rescue PolicyDenied => e
614
682
  emit(:policy_denied, { policy: e.policy, reason: e.reason }, task: task)
615
- fail_task(task, e, stage: :policy)
683
+ fail_task(task, e, stage: :policy, usage: state&.usage, profile: profile)
616
684
  rescue BudgetExceeded => e
617
685
  # WS2 hard budget: a typed, retryable failure — the envelope reads
618
686
  # budget_exceeded + retry_after (window roll), never a silent drop.
619
- fail_task(task, e, stage: :budget)
687
+ fail_task(task, e, stage: :budget, usage: state&.usage, profile: profile)
620
688
  rescue CircuitOpenError => e
621
689
  # WS3 breaker: the turn died BEFORE the provider call — the envelope
622
690
  # reads circuit_open + retry_after (cooldown remaining). Worth its own
623
691
  # stage: an open breaker is a reliability decision, not an error bug.
624
- fail_task(task, e, stage: :reliability)
692
+ fail_task(task, e, stage: :reliability, usage: state&.usage, profile: profile)
625
693
  rescue Insika::RoutingError => e
626
694
  # WS4: a route's delegate is missing or its turn failed — an operator
627
695
  # config error, staged so the envelope names routing, never :unknown.
628
- fail_task(task, e, stage: :routing)
696
+ fail_task(task, e, stage: :routing, usage: state&.usage, profile: profile)
629
697
  rescue Insika::MediaError => e
630
698
  # WS9: a voice message that could not be fetched/transcribed (or a media
631
699
  # URL the egress guard refused) — heard-loud, never a silent drop.
632
- fail_task(task, e, stage: :media)
700
+ fail_task(task, e, stage: :media, usage: state&.usage, profile: profile)
633
701
  rescue Insika::WorkflowSchemaError => e
634
702
  # a workflow OUTPUT that violates its output_schema. Distinct
635
703
  # stage so a contract breach is not conflated with an :unknown failure. (INPUT
636
704
  # is validated synchronously in TriggerWorkflow -> 422, never reaches here.)
637
- fail_task(task, e, stage: :workflow_schema)
705
+ fail_task(task, e, stage: :workflow_schema, usage: state&.usage, profile: profile)
638
706
  rescue ContextError => e
639
- fail_task(task, e, stage: :context)
707
+ fail_task(task, e, stage: :context, usage: state&.usage, profile: profile)
640
708
  rescue CapabilityError => e
641
- fail_task(task, e, stage: :capability)
709
+ fail_task(task, e, stage: :capability, usage: state&.usage, profile: profile)
642
710
  rescue ProviderError => e
643
- fail_task(task, e, stage: :ruby_llm)
711
+ fail_task(task, e, stage: :ruby_llm, usage: state&.usage, profile: profile)
644
712
  rescue StoreError => e
645
- fail_task(task, e, stage: :persistence)
713
+ fail_task(task, e, stage: :persistence, usage: state&.usage, profile: profile)
646
714
  rescue TimeoutError => e
647
- fail_task(task, e, stage: e.stage)
715
+ fail_task(task, e, stage: e.stage, usage: state&.usage, profile: profile)
648
716
  rescue StandardError => e
649
717
  # A provider/transport failure is NOT an :unknown bug: wrap it with its
650
718
  # action classification (B9) so the envelope can quote retryable and the
651
719
  # provider's own retry_after (A8). The classifier is class-name based —
652
720
  # the :ruby_llm stage stays reachable even under the smoke-shim's fake.
653
721
  if ProviderErrorClassifier.provider_error?(e)
654
- fail_task(task, ProviderErrorClassifier.wrap(e), stage: :ruby_llm)
722
+ fail_task(task, ProviderErrorClassifier.wrap(e), stage: :ruby_llm, usage: state&.usage, profile: profile)
655
723
  else
656
- fail_task(task, e, stage: :unknown)
724
+ fail_task(task, e, stage: :unknown, usage: state&.usage, profile: profile)
657
725
  end
658
726
  ensure
727
+ @shared_turns.delete(task.id)
728
+ llm_entries = @llm_buffers.delete(task.id)
659
729
  @running.delete(task.id) # ALWAYS deregister (a false-positive running? would break the resume)
730
+ # after deregistering: the writes may wait on the store, and a resume or an
731
+ # approve arriving meanwhile must not find a finished turn "running"
732
+ write_llm_diagnostics(task, llm_entries)
660
733
  # Deregistered FIRST on purpose: from here on the `steer` door finds no actor for
661
734
  # this session and answers nil, so a message arriving during the release becomes
662
735
  # its own turn instead of a post into a mailbox nobody reads again.
@@ -713,7 +786,8 @@ module Insika
713
786
  return existing if existing&.alive?
714
787
 
715
788
  @session_actors[session_id] =
716
- SessionActor.new(session_id: session_id, executor: self, parent: turn_parent)
789
+ SessionActor.new(session_id: session_id, executor: self, parent: turn_parent,
790
+ on_idle: ->(actor) { @session_actors.delete(session_id) if @session_actors[session_id].equal?(actor) })
717
791
  end
718
792
 
719
793
  # a turn that arrived while the process is draining: left
@@ -771,6 +845,7 @@ module Insika
771
845
  # the harvest engine lives on the same supervisor — its
772
846
  # worker fiber re-scans idle sessions off the turn path.
773
847
  @harvest_engine&.start(parent: @supervisor)
848
+ @memory_trim&.start(parent: @supervisor)
774
849
  @supervisor
775
850
  end
776
851
 
@@ -779,6 +854,7 @@ module Insika
779
854
  # than once in a turn collides (the 2nd reuses the 1st's decision) — per-step
780
855
  # checkpointing is a future slice. One call per tool is safe.
781
856
  def pending_id(task_id, turn, tool) = "#{task_id}:#{turn}:#{tool}"
857
+ def native_pending_id(task_id, turn, call_id) = "#{task_id}:#{turn}:call:#{call_id}"
782
858
 
783
859
  # all readings of the persisted command go through rebuild_command
784
860
  # (the single normalizer). command_type stays a STRING (the :task_started
@@ -799,7 +875,7 @@ module Insika
799
875
  # the open Execution (real TaskStore) — do NOT call finish_execution
800
876
  # (double-close). The previous checkpoint is NEVER touched on failure. Never
801
877
  # re-raises: the fiber dies clean.
802
- def fail_task(task, error, stage:)
878
+ def fail_task(task, error, stage:, usage: nil, profile: nil)
803
879
  # Defense-in-depth: if the task is already terminal (e.g. a failure in
804
880
  # cleanup AFTER transition(:completed)), completed->failed is invalid and
805
881
  # would raise ArgumentError INSIDE the rescue, leaking from the fiber. In
@@ -820,7 +896,9 @@ module Insika
820
896
  spec = { class: error.class.name, message: error.message, stage: stage }
821
897
  spec = spec.merge(classification) unless classification.empty?
822
898
  @task_store.transition(task.id, to: :failed, error: spec)
899
+ record_failed_turn(task, profile, usage, stage)
823
900
  data = { task_id: task.id, error: error.class.name, message: error.message }
901
+ data[:usage] = usage if usage
824
902
  data = data.merge(classification) unless classification.empty?
825
903
  emit(:task_failed, data, task: task)
826
904
  # a FAILED delegation child still delivers — the parent
@@ -836,10 +914,10 @@ module Insika
836
914
  # turn-timeout wrapping everything via Async::Task#with_timeout — NEVER
837
915
  # stdlib Timeout.timeout.
838
916
  def run_pipeline(task, profile, actor, resume_from, timing = nil)
839
- # a CHANNEL turn always allocates the clock — first_balloon_ms
840
- # (inbound -> first outbox flush) is H-latência and must not depend on
841
- # INSIKA_TURN_TIMING. When the flag is off the clock measures ONLY that
842
- # window (`breakdown: false`); the full prep/ttft/gen/total stays opt-in.
917
+ # Every turn runs the clock (a few clock reads): its windows become the
918
+ # turn row in ModelMetricsStore. INSIKA_TURN_TIMING only widens what the
919
+ # response exposes (`breakdown:`); with it off a channel turn still
920
+ # reports first_balloon_ms (inbound -> first outbox flush).
843
921
  #
844
922
  # A channel turn may already carry its clock: `SendMessage` stamped
845
923
  # `:inbound` at 202 acceptance (before the debounce window and the
@@ -848,9 +926,11 @@ module Insika
848
926
  # engine-initiated) falls back to allocating and stamps now — `mark` is
849
927
  # first-write-wins, so a threaded clock is never re-stamped.
850
928
  channel_turn = !channel_transport(task).nil?
851
- timing ||= if TurnTiming.enabled? || channel_turn
852
- TurnTiming.new(breakdown: TurnTiming.enabled?)
853
- end
929
+ timing ||= TurnTiming.new(breakdown: TurnTiming.enabled?)
930
+ # Store calls count into this turn's clock (Stores::TurnCounter). Assigned
931
+ # even when nil, so a turn without a clock never feeds the previous one's.
932
+ Fiber[TurnTiming::FIBER_KEY] = timing
933
+ Fiber[ConfigStore::TURN_KEY] = true # config reads from here on may be cached
854
934
  timing&.mark(:inbound) if channel_turn
855
935
  timing&.mark(:prep_start)
856
936
  state = build_turn_state(task, profile, resume_from)
@@ -863,12 +943,21 @@ module Insika
863
943
  # possibly rewritten by before_task). Subject == result == TurnState
864
944
  # (the Response content lives in the :done event).
865
945
  @hooks.around(:task, state) do |state|
866
- prepare_turn(task, profile, state, actor, resume_from) # stages 2-3 (mutates state)
946
+ yield state if block_given?
947
+ prepare = ->(st) { prepare_turn(task, profile, st, actor, resume_from) }
948
+ paid_retrieval = (Coercion.truthy?(profile.knowledge&.dig("retrieve")) && profile.knowledge&.dig("rerank")) ||
949
+ (profile.memory && profile.memory_retrieval)
950
+ middleware_options = if paid_retrieval && @middleware.is_a?(MiddlewareStack)
951
+ { prepare: prepare }
952
+ else
953
+ prepare.call(state)
954
+ {}
955
+ end
867
956
 
868
957
  # stage 4: Middleware wraps stages 5-9. A link that
869
958
  # short-circuits does NOT call the terminal and sets state.halt_reason.
870
959
  terminal_ran = false
871
- @middleware.call(state) do |st|
960
+ @middleware.call(state, **middleware_options) do |st|
872
961
  raise Insika::Error, "turn halted: #{st.halt_reason}" if st.halt_reason
873
962
 
874
963
  terminal_ran = true
@@ -909,10 +998,15 @@ module Insika
909
998
  turn = resume_from ? resume_from.turn : 1
910
999
  state = TurnState.new(task: task, profile: profile, turn: turn,
911
1000
  message: extract_message(task))
1001
+ state.caller_context = extract_caller_context(task)
1002
+ state.shared_conversations = @shared_conversations if @shared_turns[task.id]
912
1003
  state.tenant = memory_tenant(task) # WRITE-path memory scope (`remember`); =chat
913
1004
  stamp_customer_session(task, profile)
914
1005
  state.turn_context = build_turn_context(task, profile, state) # data-tools' ctx.*
915
1006
  state.resumed = !resume_from.nil? # EdgeLimiter: an admitted turn is never re-counted
1007
+ state.approval_continuation = resume_from&.continuation
1008
+ state.usage = state.approval_continuation&.fetch("usage", nil)&.transform_keys(&:to_sym)
1009
+ state.fence_max_chars = fence_max_chars if Insika::Fence.enabled?(profile)
916
1010
  # resolved for the RUN, not per message, so what the turn accepts cannot
917
1011
  # change under it. Same cost as the EdgeLimiter's per-turn resolution. Only for a
918
1012
  # SESSION turn: steering needs a session to arrive through, and resolving here for a
@@ -924,6 +1018,10 @@ module Insika
924
1018
  # validator/enforcer read the union, stage 8 flushes.
925
1019
  state.evidence_ledger = Insika::EvidenceLedger.new(store: @session_store,
926
1020
  session_id: task.session_id)
1021
+ if (evidence = state.approval_continuation&.fetch("evidence", nil))
1022
+ state.evidence_ledger.record(evidence["ids"])
1023
+ state.evidence_ledger.record_cards(evidence["cards"])
1024
+ end
927
1025
  state
928
1026
  end
929
1027
 
@@ -980,6 +1078,7 @@ module Insika
980
1078
  # wire the approval gate into the state (the ToolEnvelope reads it at stage 6).
981
1079
  state.actor = actor
982
1080
  state.approval_coordinator = self
1081
+ state.pending_action_store = @pending_action_store
983
1082
  state.requires_approval = resolution.requires_approval
984
1083
  state.allowed_tools = wrap_tools(assemble_tool_instances(resolution.allowed_tools, state), state, skip)
985
1084
  state.allowed_skills = resolution.allowed_skills
@@ -1066,7 +1165,7 @@ module Insika
1066
1165
  # approximating RubyLLM's rendering (honest in the doc: the reason's job is
1067
1166
  # the CONTEXT categories; the tool hash is a guard rail).
1068
1167
  def serialize_tools(tools)
1069
- tools.map { |t| "#{t.name} #{t.description} #{t.parameters.inspect}" }.join(" ")
1168
+ tools.map { |t| "#{t.name} #{t.description} #{t.parameters_schema.inspect}" }.join(" ")
1070
1169
  end
1071
1170
 
1072
1171
  # the stage-8 stamp — the usage (cached_tokens,
@@ -1146,7 +1245,7 @@ module Insika
1146
1245
  # Same yardstick as the fragments (TokenEstimator), so the categories are
1147
1246
  # comparable. `parameters` is not guaranteed JSON-safe — inspect it.
1148
1247
  def estimate_tools_tokens(tools)
1149
- TokenEstimator.estimate(tools.map { |t| "#{t.name} #{t.description} #{t.parameters.inspect}" }.join(" "))
1248
+ TokenEstimator.estimate(tools.map { |t| "#{t.name} #{t.description} #{t.parameters_schema.inspect}" }.join(" "))
1150
1249
  rescue StandardError
1151
1250
  0
1152
1251
  end
@@ -1200,6 +1299,7 @@ module Insika
1200
1299
  # but is not honored (last stage); :cancel here still raises.
1201
1300
  actor.drain!
1202
1301
  persist_turn(task, profile, st, content, timing: timing)
1302
+ expire_customer_confirmations(task)
1203
1303
 
1204
1304
  # the cache-hit stamp — the usage exists only now. The
1205
1305
  # stamped entry is durable before anything is delivered (same slot as the
@@ -1220,11 +1320,12 @@ module Insika
1220
1320
  # Additive sibling — the answer text stays text on purpose; the channel
1221
1321
  # consumes the parts next to it. Absent when nothing was generated.
1222
1322
  data[:output_parts] = st.output_parts if st.output_parts && !st.output_parts.empty?
1223
- data[:timing] = timing.to_h if timing # opt-in TTFB breakdown (INSIKA_TURN_TIMING)
1323
+ exposed = timing&.to_h
1324
+ data[:timing] = exposed unless exposed.nil? || exposed.empty? # opt-in TTFB breakdown (INSIKA_TURN_TIMING)
1224
1325
  # best-effort persist of the same timing onto the task record —
1225
1326
  # the Studio task page reads it from there. A failure here must not re-fail
1226
1327
  # the turn (the task is already committed and the event already carries it).
1227
- persist_turn_timing(task, timing)
1328
+ persist_turn_timing(task, timing, profile, st.usage)
1228
1329
  # WS5: the agent declared it cannot proceed (signal_stuck). The turn still
1229
1330
  # COMPLETES (its final message was published) — but the consumer must be able
1230
1331
  # to act on that, so the contract carries it twice: a dedicated :turn_stuck
@@ -1304,13 +1405,6 @@ module Insika
1304
1405
  # release the redactor's retained tail (a value that never completed into a
1305
1406
  # match is emitted redacted-if-needed, not lost) before reading anything back.
1306
1407
  output.flush
1307
- # MERGED, not overwritten: a WS4 routing call already banked its tokens in
1308
- # state.usage before the ask — the classifier's cost must survive the ask
1309
- # (it feeds the EdgeLimiter's ceiling/budget and the terminal usage).
1310
- unless halted?(response)
1311
- state.usage = merge_usage(with_model_source(usage_of(response), state.model_selection),
1312
- state.usage)
1313
- end
1314
1408
 
1315
1409
  # BOUNDARY BEFORE THE ANSWER GOES OUT. A cancel that arrived while the provider
1316
1410
  # was working used to be observed at stage 8 — AFTER `:content` had already been
@@ -1322,10 +1416,23 @@ module Insika
1322
1416
  # holding a completed answer for an operator would strand it unpublished.
1323
1417
  state.actor&.drain!
1324
1418
 
1325
- output.publish(turn_answer(response, asked, output, filter))
1419
+ output.publish(without_prompt_echo(state, turn_answer(response, asked, output, filter)))
1326
1420
  end
1327
1421
  end
1328
1422
 
1423
+ # The last gate before the customer reads the answer: a reply that pasted the
1424
+ # system prompt keeps only what came before it (or the safe refusal when that is
1425
+ # nothing). The cut rides :guardrail_flagged like any other, with action "cut".
1426
+ def without_prompt_echo(state, answer)
1427
+ text, echoed = Insika::Safety::PromptEcho.cut(answer, Insika::ModelVisible.instructions_of(state.chat))
1428
+ return answer if echoed.zero?
1429
+
1430
+ state.guardrail_flags = Array(state.guardrail_flags) +
1431
+ [{ category: "prompt_leak", source: :deterministic, action: "cut",
1432
+ detail: "#{echoed} chars of the system prompt removed" }]
1433
+ text.empty? ? Insika::Safety::SafeResponses.for(:injection) : text
1434
+ end
1435
+
1329
1436
  # stage 6, plain path: the single ask on the assembled chat. Fresh TurnOutput
1330
1437
  # + steer wiring per interaction (registered on state.chat, which the solve
1331
1438
  # already assembled). -> { response:, asked:, output: }.
@@ -1336,8 +1443,8 @@ module Insika
1336
1443
  { response: asked, asked: asked, output: output }
1337
1444
  end
1338
1445
 
1339
- # stage 6, WS3 path: the Reliability coordinator drives retries, backoff,
1340
- # circuit breaker and the fallback rotation. Each ATTEMPT gets a fresh chat
1446
+ # Native chats own transport retries. Reliability keeps the circuit breaker
1447
+ # and fallback rotation (and retries for non-native integrations). Each rotation gets a fresh chat
1341
1448
  # + output (a failed `ask` leaves its message in the chat, so re-asking the
1342
1449
  # same one would double the input) and, on a fallback, state.model_selection
1343
1450
  # follows — the turn's usage is attributed to the model that actually spoke
@@ -1349,8 +1456,10 @@ module Insika
1349
1456
  attempt_output = nil
1350
1457
  attempt_asked = nil
1351
1458
  primary = state.model_selection
1459
+ # Native transport owns retries per request; the coordinator only rotates models.
1460
+ coordinator_policy = state.chat.is_a?(RubyLLM::Chat) ? policy.merge("retries" => 0) : policy
1352
1461
  response = @reliability.call(
1353
- policy: policy, tenant: task_tenant(task), agent: state.profile.id.to_s,
1462
+ policy: coordinator_policy, tenant: task_tenant(task), agent: state.profile.id.to_s,
1354
1463
  selection: primary, chain: reliability_chain(state)
1355
1464
  ) do |selection, tries|
1356
1465
  first_attempt = state.chat && selection == primary && tries == 1
@@ -1361,7 +1470,9 @@ module Insika
1361
1470
  chat = build_attempt_chat(state, selection)
1362
1471
  state.chat = chat
1363
1472
  end
1473
+ filter&.flush # Discard the failed stream's retained tail before the next attempt.
1364
1474
  attempt_output = new_turn_output(task, state, filter)
1475
+ configure_reliable_chat(state.chat, policy)
1365
1476
  wire_chat_output(task, state, attempt_output)
1366
1477
  attempt_asked = ask_on(task, state, state.chat, attempt_output, timing)
1367
1478
  attempt_asked
@@ -1369,6 +1480,19 @@ module Insika
1369
1480
  { response: response, asked: attempt_asked, output: attempt_output }
1370
1481
  end
1371
1482
 
1483
+ def configure_reliable_chat(chat, policy)
1484
+ return unless chat.is_a?(RubyLLM::Chat)
1485
+
1486
+ config = (chat.context&.config || RubyLLM.config).dup
1487
+ config.max_retries = [policy["retries"].to_i, 0].max
1488
+ timeout = policy["timeout"].to_i
1489
+ config.request_timeout = timeout.positive? ? timeout : Reliability::DEFAULT_TIMEOUT
1490
+ if policy["backoff"]
1491
+ config.retry_backoff_factor = policy["backoff"] == "exponential" ? 2 : 1
1492
+ end
1493
+ chat.with_context(RubyLLM::Context.new(config))
1494
+ end
1495
+
1372
1496
  # The fallback chain for WS3: profile's `reliability["fallback"]` refs first,
1373
1497
  # then the platform-resolved fallbacks (ModelSelection). Each node is a
1374
1498
  # ModelSelection (the SAME duck the primary is — usage attribution and
@@ -1376,6 +1500,8 @@ module Insika
1376
1500
  # primary. Deduped by ref, primary excluded.
1377
1501
  def reliability_chain(state)
1378
1502
  primary = state.model_selection
1503
+ return [] if primary.pinned?
1504
+
1379
1505
  refs = Array((state.profile.reliability || {})["fallback"]).map(&:to_s)
1380
1506
  nodes = refs.filter_map { |r| parse_model_ref(r) }.reject { |n| n[:model].to_s.empty? }
1381
1507
  nodes.concat(Array(primary.fallbacks).map { |f| { model: f[:model], provider: f[:provider] } })
@@ -1392,6 +1518,19 @@ module Insika
1392
1518
  : seen.keys.any? { |known| known.split("/").last == ref }
1393
1519
  next if duplicate
1394
1520
 
1521
+ unless ModelPolicy.allowed?(state.profile.model_policy, model: node[:model], provider: node[:provider])
1522
+ next if node[:provider]
1523
+ next unless Array(ModelPolicy.allow_list(state.profile.model_policy)).any? { |allowed| allowed.to_s.include?("/") }
1524
+
1525
+ begin
1526
+ node[:provider] = RubyLLM.models.find(node[:model]).provider.to_sym
1527
+ rescue RubyLLM::ModelNotFoundError
1528
+ next
1529
+ end
1530
+ next unless ModelPolicy.allowed?(state.profile.model_policy, model: node[:model], provider: node[:provider])
1531
+ ref = model_ref(node)
1532
+ end
1533
+
1395
1534
  seen[ref] = true
1396
1535
  Insika::ModelSelection.new(model: node[:model], provider: node[:provider],
1397
1536
  source: :fallback, params: primary.params, fallbacks: [])
@@ -1410,6 +1549,11 @@ module Insika
1410
1549
  # for data/HTTP tools. The engine transports media, never meaning: no
1411
1550
  # speech/vision logic beyond the call itself.
1412
1551
  def run_media_stage(task, state)
1552
+ if state.shared_conversations
1553
+ attachments = state.shared_conversations.input_attachments(task: task)
1554
+ state.media_attachments = attachments unless attachments.empty?
1555
+ return
1556
+ end
1413
1557
  # a consumer that pre-transcribed voice text labels it `source: voice`;
1414
1558
  # the marker rides the turn even when there are no audio PARTS left.
1415
1559
  state.message_source = :voice if rebuild_command(task).payload["source"].to_s == "voice"
@@ -1467,7 +1611,7 @@ module Insika
1467
1611
  transcriber = @media || Insika::Media.default_transcriber(
1468
1612
  stt_model: Insika::EnvSchema.read("INSIKA_STT_MODEL"),
1469
1613
  stt_language: Insika::EnvSchema.read("INSIKA_STT_LANGUAGE"),
1470
- stt_prompt: resolved_stt_prompt(state.profile)
1614
+ stt_prompt: resolved_stt_prompt(state.profile), context: @llm
1471
1615
  )
1472
1616
  transcriber.call(url)
1473
1617
  end
@@ -1551,8 +1695,9 @@ module Insika
1551
1695
  # "routing", same turn number as the answer ask).
1552
1696
  def route_ask(selection, prompt, message, task, st)
1553
1697
  require "ruby_llm"
1554
- chat = (@llm || RubyLLM).chat(model: selection.model, provider: selection.provider,
1555
- assume_model_exists: selection.assume_model_exists?)
1698
+ chat = llm_operation_context(st, selection.model, operation: "routing")
1699
+ .chat(model: selection.model, provider: selection.provider,
1700
+ assume_model_exists: selection.assume_model_exists?)
1556
1701
  chat.with_instructions(prompt) if chat.respond_to?(:with_instructions)
1557
1702
  response = chat.ask(message.to_s)
1558
1703
  record_model_visible(task, st, chat, part: "routing")
@@ -1603,7 +1748,7 @@ module Insika
1603
1748
  "routed delegate '#{agent_id}' at depth #{depth} exceeds cap #{cap}"
1604
1749
  end
1605
1750
 
1606
- result = spawn_and_await_child(child, state.message, depth, state)
1751
+ result = spawn_and_await_child(child, ask_text(state), depth, state)
1607
1752
  if result[:error]
1608
1753
  raise Insika::RoutingError, "routed delegate '#{agent_id}' failed: #{result[:error]}"
1609
1754
  end
@@ -1617,12 +1762,18 @@ module Insika
1617
1762
  def merge_usage(main, extra)
1618
1763
  return main || extra if main.nil? || extra.nil?
1619
1764
 
1620
- Insika::Routing::TOKEN_FIELDS.each_with_object(main.dup) do |k, acc|
1765
+ merged = Insika::Routing::TOKEN_FIELDS.each_with_object(extra.merge(main)) do |k, acc|
1621
1766
  next if extra[k].nil?
1622
1767
  next unless main.key?(k) || extra[k].to_i.positive?
1623
1768
 
1624
1769
  acc[k] = main[k].to_i + extra[k].to_i
1625
1770
  end
1771
+ if main.key?(:cost_usd) || extra.key?(:cost_usd)
1772
+ costs = [main[:cost_usd], extra[:cost_usd]]
1773
+ merged[:cost_usd] = costs.sum unless costs.any?(&:nil?)
1774
+ merged[:cost_usd] = nil if costs.any?(&:nil?)
1775
+ end
1776
+ merged
1626
1777
  end
1627
1778
 
1628
1779
  # "provider/model" for any selection duck (ModelSelection | { model:, provider: }).
@@ -1654,7 +1805,7 @@ module Insika
1654
1805
  # state (the seed history is identical), baseline reset -> the transcript
1655
1806
  # recorded from than point is the attempt that spoke.
1656
1807
  def build_attempt_chat(state, selection)
1657
- chat = build_chat(selection, state.model_selection)
1808
+ chat = build_chat(selection, state.model_selection, state: state)
1658
1809
  @chat_builder.assemble(chat, state, emit: ->(type, data) { emit(type, data, task: state.task) })
1659
1810
  state.chat_baseline = Array(chat.messages).size if chat.respond_to?(:messages)
1660
1811
  chat
@@ -1662,6 +1813,7 @@ module Insika
1662
1813
 
1663
1814
  def new_turn_output(task, state, filter)
1664
1815
  TurnOutput.new(filter: filter, emit: ->(type, data) { emit(type, data, task: task) },
1816
+ halt_text: state.approval_continuation&.fetch("halt_text", ""),
1665
1817
  public_intermediate: state.profile.stream_public?(:intermediate))
1666
1818
  end
1667
1819
 
@@ -1670,6 +1822,7 @@ module Insika
1670
1822
  # anything is appended after it — the gem's callbacks are additive and run
1671
1823
  # in registration order.
1672
1824
  def wire_chat_output(task, state, output)
1825
+ state.turn_output = output
1673
1826
  chat = state.chat
1674
1827
  chat.after_message { |message| output.message_ended(message) } if chat.respond_to?(:after_message)
1675
1828
  state.steer_injector = install_steer_injector(task, state)
@@ -1693,16 +1846,10 @@ module Insika
1693
1846
  return if halted?(result[:response])
1694
1847
  return if injector.absorb_pending!.zero?
1695
1848
 
1696
- # TWO provider round trips now. Bank the first one's tokens BEFORE the response
1697
- # is replaced — stage 6's merge only sees whatever `result[:response]` ends up
1698
- # being, so without this the extra round silently erases the first round's cost
1699
- # from the terminal usage and from the EdgeLimiter's budget.
1700
- state.usage = merge_usage(with_model_source(usage_of(result[:response]), state.model_selection),
1701
- state.usage)
1702
1849
  # COMPLETE, not `ask(nil)`: the messages are already in the history, and an
1703
1850
  # ask with no text appends an EMPTY user message after them — which is both a
1704
1851
  # duplicate turn-opener and something providers refuse outright.
1705
- extra = state.chat.complete(&turn_chunk_handler(task, state, result[:output], timing))
1852
+ extra = complete_chat(state, state.chat, &turn_chunk_handler(task, state, result[:output], timing))
1706
1853
  result[:response] = extra
1707
1854
  result[:asked] = extra
1708
1855
  end
@@ -1720,12 +1867,150 @@ module Insika
1720
1867
  # An image with no caption asks with NIL, not "": an empty text part is a
1721
1868
  # thing some providers refuse, and nil is how RubyLLM says "attachments
1722
1869
  # only".
1723
- if state.media_attachments
1724
- text = state.message.to_s.empty? ? nil : state.message
1725
- chat.ask(text, with: state.media_attachments, &each_chunk)
1870
+ if state.native_approvals && state.approval_continuation
1871
+ continuation = state.approval_continuation
1872
+ @chat_builder.seed_history(chat, continuation.fetch("messages"))
1873
+ if continuation.key?("halt")
1874
+ close_halted_tool_calls(state, chat)
1875
+ return ToolDefinition::Halt.new(content: continuation["halt"])
1876
+ end
1877
+ elsif state.media_attachments
1878
+ text = ask_text(state)
1879
+ chat.ask_later(text.empty? ? nil : text, with: state.media_attachments)
1726
1880
  else
1727
- chat.ask(state.message, &each_chunk)
1881
+ chat.ask_later(ask_text(state))
1728
1882
  end
1883
+ complete_chat(state, chat, &each_chunk)
1884
+ end
1885
+
1886
+ def complete_chat(state, chat, &on_chunk)
1887
+ before = usage_of(chat)
1888
+ state.tool_halt = nil
1889
+ response = nil
1890
+ loop do
1891
+ if chat.awaiting_approval?
1892
+ break unless state.native_approvals
1893
+
1894
+ save_approval_continuation(state, chat, before)
1895
+ await_native_approvals(state, chat)
1896
+ end
1897
+ response = chat.step(&on_chunk)
1898
+ recover_text_tool_call(state, chat, response)
1899
+ close_halted_tool_calls(state, chat) if state.native_approvals && state.tool_halt
1900
+ save_approval_continuation(state, chat, before) if state.tool_halt || !chat.complete?
1901
+ break if state.tool_halt || chat.complete?
1902
+ end
1903
+ state.tool_halt || response
1904
+ ensure
1905
+ # Native chat totals include tool rounds and failed provider attempts. Take
1906
+ # a delta because a steered message can continue this same chat once more.
1907
+ usage = chat_usage_since(chat, before, response)
1908
+ state.usage = merge_usage(with_model_source(usage, state.model_selection), state.usage) if usage
1909
+ end
1910
+
1911
+ # The model wrote the call as TEXT (see TextToolCall). Swap that message for the
1912
+ # tool call it meant, BEFORE the loop checks `complete?`: the chat then has a
1913
+ # pending call, the next step runs the tool, and the model answers from the
1914
+ # result. The swapped message is what the session persists, so the markup never
1915
+ # reaches the history. TurnOutput already took the markup as the answer
1916
+ # candidate at the boundary; the message that ends the turn now replaces it.
1917
+ def recover_text_tool_call(state, chat, response)
1918
+ # RubyLLM is required lazily (the load-guard); FakeChat answers plain structs.
1919
+ return unless defined?(RubyLLM::Message) && response.is_a?(RubyLLM::Message)
1920
+ return unless response.role == :assistant && !response.tool_call?
1921
+ return unless chat.messages.last.equal?(response)
1922
+
1923
+ parsed = TextToolCall.parse(response.content, chat.tools.keys.map(&:to_s)) or return
1924
+ calls = parsed[:calls].to_h do |call|
1925
+ id = "call_text_#{SecureRandom.hex(6)}"
1926
+ [id, RubyLLM::ToolCall.new(id: id, name: call[:name], arguments: call[:arguments])]
1927
+ end
1928
+ chat.messages.pop
1929
+ chat.add_message(RubyLLM::Message.new(
1930
+ role: :assistant, content: parsed[:text], tool_calls: calls, model: response.model,
1931
+ tokens: response.tokens, thinking: response.thinking, raw_reasoning: response.raw_reasoning
1932
+ ))
1933
+ names = calls.values.map(&:name)
1934
+ # One line per recovery in the deploy log: it is how the rate of text tool
1935
+ # calls is measured per agent.
1936
+ warn "[insika] text tool call recovered agent=#{state.profile&.id} tools=#{names.join(',')}"
1937
+ emit(:tool_call_recovered, { tools: names }, task: state.task) if state.task
1938
+ end
1939
+
1940
+ def chat_usage_since(chat, before, response = nil)
1941
+ usage = chat.respond_to?(:tokens) ? usage_of(chat) : usage_of(response)
1942
+ if usage
1943
+ if before
1944
+ Insika::Routing::TOKEN_FIELDS.each do |key|
1945
+ usage[key] -= before[key].to_i if usage.key?(key)
1946
+ end
1947
+ if usage[:cost_usd]
1948
+ prior_reported = Insika::Routing::TOKEN_FIELDS.any? { |key| before.key?(key) }
1949
+ usage[:cost_usd] = if before[:cost_usd] || !prior_reported
1950
+ usage[:cost_usd] - before[:cost_usd].to_f
1951
+ end
1952
+ end
1953
+ end
1954
+ end
1955
+ usage
1956
+ end
1957
+
1958
+ # A terminal result can leave approval-gated siblings unanswered. Close them
1959
+ # without executing them so the persisted batch is valid on the next turn.
1960
+ def close_halted_tool_calls(state, chat)
1961
+ index = chat.messages.rindex { |message| message.role == :assistant && message.tool_call? }
1962
+ return unless index
1963
+
1964
+ answered = chat.messages.drop(index + 1).filter_map(&:tool_call_id)
1965
+ chat.messages[index].tool_calls.each_value do |call|
1966
+ action = @pending_action_store&.find(native_pending_id(state.task.id, state.turn, call.id))
1967
+ if action&.status == :pending
1968
+ @pending_action_store.resolve(action.id, decision: :rejected, operator: "engine:halted")
1969
+ emit(:approval_resolved, { pending_id: action.id, decision: :rejected,
1970
+ task_id: state.task.id, resolved_by: "engine:halted" }, task: state.task)
1971
+ end
1972
+ next if answered.include?(call.id)
1973
+
1974
+ chat.add_message(role: :tool, tool_call_id: call.id,
1975
+ content: JSON.generate(error: "Tool call not executed because the turn halted."))
1976
+ end
1977
+ end
1978
+
1979
+ def save_approval_continuation(state, chat, before)
1980
+ return unless state.native_approvals
1981
+ return unless state.approval_continuation || state.profile.reliability || chat.tools.values.any?(&:requires_approval?)
1982
+
1983
+ continuation = {
1984
+ "messages" => recorded_turn_messages(state, chat: chat),
1985
+ "tools" => chat.tools.keys.map(&:to_s),
1986
+ "halt_text" => state.turn_output&.halt_text,
1987
+ "usage" => merge_usage(with_model_source(chat_usage_since(chat, before), state.model_selection), state.usage),
1988
+ "evidence" => { "ids" => state.evidence_ledger&.ids, "cards" => state.evidence_ledger&.cards }
1989
+ }
1990
+ continuation["halt"] = state.tool_halt.content if state.tool_halt
1991
+ saved = @checkpoint_store.save_continuation(state.task.id, turn: state.turn, continuation: continuation)
1992
+ state.approval_continuation = saved.continuation
1993
+ end
1994
+
1995
+ def await_native_approvals(state, chat)
1996
+ task = state.task
1997
+ chat.pending_approvals.each do |call|
1998
+ tool = chat.tools[call.name.to_sym]
1999
+ unless tool.respond_to?(:approval_tool_name)
2000
+ raise Insika::Error, "native approval has no Insika tool binding"
2001
+ end
2002
+ name = tool.approval_tool_name
2003
+ approval_decision(task: task, turn: state.turn, tool: name, args: call.arguments, call_id: call.id)
2004
+ id = native_pending_id(task.id, state.turn, call.id)
2005
+ next if @pending_action_store.find(id)
2006
+
2007
+ @pending_action_store.create(id: id, task_id: task.id, session_id: task.session_id,
2008
+ turn: state.turn, tool: name, args: call.arguments)
2009
+ emit(:approval_requested, { pending_id: id, tool: name, args: call.arguments }, task: task)
2010
+ end
2011
+ @task_store.transition(task.id, to: :waiting) if @task_store.find(task.id).status == :running
2012
+ state.actor.await(reason: :approval) while chat.awaiting_approval?
2013
+ @task_store.transition(task.id, to: :running)
1729
2014
  end
1730
2015
 
1731
2016
  # The per-round chunk sink. Built FRESH per round: `ttft_sent` is that round's
@@ -1787,7 +2072,7 @@ module Insika
1787
2072
  # stage, because the `:agent` after-hook runs after the message boundary and is
1788
2073
  # allowed to have the last word.
1789
2074
  def turn_answer(response, asked, output, filter)
1790
- # HALTED BY A TOOL RESULT (`halt_when`): RubyLLM returns the Tool::Halt itself
2075
+ # HALTED BY A TOOL RESULT (`halt_when`): complete_chat returns the marker
1791
2076
  # instead of a Message, and its `content` is the tool PAYLOAD — publishing it
1792
2077
  # would ship the envelope to the customer as the answer. The turn is worth
1793
2078
  # exactly the lead-in of the message that called the tool (the model's "vou te
@@ -1820,7 +2105,7 @@ module Insika
1820
2105
  Insika::ToolDefinition.halt_say_of(content_of(response)).to_s
1821
2106
  end
1822
2107
 
1823
- def halted?(response) = response.is_a?(RubyLLM::Tool::Halt)
2108
+ def halted?(response) = response.is_a?(Insika::ToolDefinition::Halt)
1824
2109
 
1825
2110
  def content_of(response) = response.respond_to?(:content) ? response.content : response.to_s
1826
2111
 
@@ -1865,31 +2150,33 @@ module Insika
1865
2150
  return usage if usage.nil? || selection.nil?
1866
2151
 
1867
2152
  usage[:model_source] = selection.source
1868
- usage[:model] ||= selection.model # falls back to the resolved id when the provider omits it
2153
+ unless usage[:model] # the provider omitted it: the resolved id stands in, and prices it
2154
+ usage[:model] = selection.model
2155
+ usage[:cost_usd] ||= @pricing&.cost(usage)
2156
+ end
2157
+ provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
2158
+ usage[:provider] ||= provider.to_s if provider
1869
2159
  usage
1870
2160
  end
1871
2161
 
1872
- # Token usage of the provider's response (RubyLLM::Message exposes
1873
- # input_tokens/output_tokens/cached_tokens/model_id). Duck-typed: a provider/
1874
- # fake with no counting -> nil (nothing to report). Shape compatible with the
1875
- # OpenAI Responses usage (input/output/total) + model, consumed by
1876
- # /v1/responses and Telemetry.
2162
+ # Internal buckets are disjoint; total_tokens is the input/output subtotal.
2163
+ # The Responses adapter projects cache-inclusive input/total at the HTTP edge.
2164
+ # Budgets and evals already add the separate cache buckets to this subtotal.
1877
2165
  def usage_of(response)
1878
- return nil unless response.respond_to?(:input_tokens)
1879
-
1880
- input = response.input_tokens.to_i
1881
- output = response.output_tokens.to_i
1882
- usage = { input_tokens: input, output_tokens: output, total_tokens: input + output }
1883
- if response.respond_to?(:cached_tokens) && response.cached_tokens
1884
- usage[:cached_tokens] = response.cached_tokens.to_i # cache_read_input_tokens
1885
- end
1886
- # R3: prompt-cache WRITE tokens (Anthropic cache_creation_input_tokens),
1887
- # billed at ~1.25x. Reported so the first (write) turn vs later (read) turns
1888
- # are distinguishable in telemetry/usage.
1889
- if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens
1890
- usage[:cache_creation_tokens] = response.cache_creation_tokens.to_i
2166
+ return nil unless response.respond_to?(:tokens) && (tokens = response.tokens)
2167
+
2168
+ usage = { input_tokens: tokens.input, output_tokens: tokens.output,
2169
+ cached_tokens: tokens.cache_read, cache_creation_tokens: tokens.cache_write }.compact
2170
+ reported = [tokens.input, tokens.output].compact
2171
+ usage[:total_tokens] = reported.sum unless reported.empty?
2172
+ if response.respond_to?(:model) && (model = response.model)
2173
+ usage[:model] = model.respond_to?(:id) ? model.id : model.to_s
1891
2174
  end
1892
- usage[:model] = response.model_id.to_s if response.respond_to?(:model_id) && response.model_id
2175
+ # Priced per response under ITS model (RubyLLM's cost; the operator table
2176
+ # only when RubyLLM has none), so a turn that summed a failed primary, a
2177
+ # fallback and the router costs each at its own rate.
2178
+ usage[:cost_usd] = response.cost.total if response.respond_to?(:cost)
2179
+ usage[:cost_usd] = @pricing.cost(usage) if usage[:cost_usd].nil? && @pricing
1893
2180
  usage
1894
2181
  end
1895
2182
 
@@ -1905,6 +2192,17 @@ module Insika
1905
2192
  payload["message"] || payload["input"]
1906
2193
  end
1907
2194
 
2195
+ def extract_caller_context(task)
2196
+ text = rebuild_command(task).payload["context"].to_s.strip
2197
+ text.empty? ? nil : text
2198
+ end
2199
+
2200
+ # What the model is asked and the transcript keeps: the caller's context, then
2201
+ # the message. Guardrails scanned `message` alone, before this point.
2202
+ def ask_text(state)
2203
+ [state.caller_context, state.message.to_s].compact.reject(&:empty?).join("\n\n")
2204
+ end
2205
+
1908
2206
  def command_history(task)
1909
2207
  rebuild_command(task).payload["history"]
1910
2208
  end
@@ -1912,19 +2210,27 @@ module Insika
1912
2210
  def build_context_request(task, profile, state, resume_from)
1913
2211
  session = task.session_id ? @session_store.find(task.session_id) : nil
1914
2212
  state.session = session # create_chat reads it for the per-chat model pin
1915
- hist = command_history(task)
2213
+ hist = @shared_turns.dig(task.id, :history) || command_history(task)
1916
2214
  # `vars` reconciles the seam (the Request/Session provider already
1917
2215
  # called request.vars): session metadata + the explicit `history` in the
1918
2216
  # convention the Session provider consumes (vars["history"]).
1919
2217
  vars = (session&.vars || {}).dup
1920
2218
  vars["history"] = hist if hist
2219
+ vars["shared_memory"] = @shared_turns[task.id][:memory] if @shared_turns[task.id]
1921
2220
  # The single type is Insika::ContextRequest (Data); the explicit `history`
1922
2221
  # travels in vars["history"] (Session provider convention), not in a field
1923
2222
  # of its own. `memory_scope` is the WS8 customer cell (nil = the providers
1924
2223
  # fall back to tenant || session, today's behavior).
2224
+ turn = state.turn
1925
2225
  ContextRequest.new(profile: profile, message: state.message, session: session,
1926
2226
  checkpoint: resume_from, tenant: command_tenant(task), vars: vars,
1927
- memory_scope: memory_tenant(task))
2227
+ memory_scope: memory_tenant(task),
2228
+ diagnostics: lambda { |type, data|
2229
+ if type == :llm_usage && data["operation"] == "rerank"
2230
+ (state.rerank_usage ||= []) << data
2231
+ end
2232
+ emit(type, data.merge("turn" => turn), task: task)
2233
+ })
1928
2234
  end
1929
2235
 
1930
2236
  # task_started payload. Carries the EXPLICIT command tenant so
@@ -1969,19 +2275,8 @@ module Insika
1969
2275
  # indistinguishable from a single-tenant customer ref, and the Studio drill
1970
2276
  # must not list conversations as customers with a Forget button.
1971
2277
  def memory_tenant(task)
1972
- customer = command_customer(task)
1973
- return command_tenant(task) || session_scope(task.session_id) if customer.nil?
1974
-
1975
- [command_tenant(task), customer].compact.join(":")
1976
- end
1977
-
1978
- # The marked per-session scope : "chat:<session id>" -> cell
1979
- # "memory:chat:<session id>". nil for a one-shot turn (no session) — the
1980
- # MemoryStore applies _default.
1981
- def session_scope(session_id)
1982
- return nil if session_id.nil?
1983
-
1984
- "#{MemoryStore::SESSION_TAG}:#{session_id}"
2278
+ MemoryStore.scope_for(tenant: command_tenant(task), customer: command_customer(task),
2279
+ session_id: task.session_id)
1985
2280
  end
1986
2281
 
1987
2282
  # WS8 + : stamp the customer (WS8 — the `forget_customer`
@@ -1989,16 +2284,22 @@ module Insika
1989
2284
  # distillation engine resolves each session's pack through it) on the
1990
2285
  # session ONCE (idempotent). A session that does not exist yet (no
1991
2286
  # session_id on the turn) is skipped; a look-up failure never breaks the
1992
- # turn.
2287
+ # turn. The optional labels (SessionStore::CUSTOMER_LABELS: name, phone)
2288
+ # are NOT stamped once: they follow the latest value the caller sends.
1993
2289
  def stamp_customer_session(task, profile)
1994
2290
  customer = command_customer(task)
1995
- return if customer.nil? || task.session_id.nil?
2291
+ labels = command_customer_labels(task)
2292
+ return if (customer.nil? && labels.empty?) || task.session_id.nil?
1996
2293
 
1997
2294
  session = @session_store&.find(task.session_id)
1998
- return if session.nil? || !Coercion.presence(session.vars["customer"]).nil?
2295
+ return if session.nil?
1999
2296
 
2000
- @session_store.update_vars(task.session_id,
2001
- "customer" => customer, "agent" => profile.id)
2297
+ vars = {}
2298
+ if customer && Coercion.presence(session.vars["customer"]).nil?
2299
+ vars.merge!("customer" => customer, "agent" => profile.id)
2300
+ end
2301
+ vars.merge!(labels.reject { |k, v| session.vars[k] == v })
2302
+ @session_store.update_vars(task.session_id, vars) unless vars.empty?
2002
2303
  rescue Insika::NotFoundError, ArgumentError
2003
2304
  nil
2004
2305
  end
@@ -2011,17 +2312,31 @@ module Insika
2011
2312
  Coercion.presence(rebuild_command(task).payload["customer"])
2012
2313
  end
2013
2314
 
2315
+ # The optional labels on the command payload (customer_name, customer_phone):
2316
+ # how the Studio shows and finds the conversation. Only a non-blank String
2317
+ # counts; anything else is ignored.
2318
+ def command_customer_labels(task)
2319
+ payload = rebuild_command(task).payload
2320
+ SessionStore::CUSTOMER_LABELS.filter_map do |key|
2321
+ value = payload[key]
2322
+ value.is_a?(String) && (value = Coercion.presence(value)) ? [key, value] : nil
2323
+ end.to_h
2324
+ end
2325
+
2014
2326
  # Turn context: the ids the data-tools resolve via
2015
2327
  # {{ctx.*}} to emit X-Chat-Id/X-Store-Id/X-Agent-Id to /api/internal/*. They
2016
- # come from the TURN, never from the model args (R2). chat_id = the session
2017
- # (the /v1/responses adapter creates the session with id = user = chat.id);
2328
+ # come from the TURN, never from the model args (R2). chat_id is the external
2329
+ # session id, without the server-added tenant namespace;
2018
2330
  # tenant = the Command tenant (memory) OR chat_id (drop-in default); agent_id =
2019
2331
  # profile; store_id = the profile metadata (stable per store, from the pack).
2020
2332
  # Absent fields -> nil (the data-tool emits an empty header; in the pilot the
2021
2333
  # profile carries store_id). Generic: nothing here mentions a consumer.
2022
2334
  def build_turn_context(task, profile, state)
2335
+ tenant = command_tenant(task)
2023
2336
  {
2024
- chat_id: task.session_id,
2337
+ chat_id: tenant ? task.session_id&.delete_prefix("#{tenant}:") : task.session_id,
2338
+ # the conversation itself — MCP clients are kept per conversation.
2339
+ session_id: task.session_id,
2025
2340
  agent_id: profile.id,
2026
2341
  # the DATA-TOOL header tenant stays the merchant (or the chat), even when
2027
2342
  # the memory scope carries a customer — the backend identifies the store,
@@ -2336,6 +2651,7 @@ module Insika
2336
2651
 
2337
2652
  @task_store.transition(task.id, to: :paused)
2338
2653
  emit(:task_paused, { task_id: task.id }, task: task)
2654
+ flush_llm_diagnostics(task) # the wait may last hours
2339
2655
  actor.await(reason: :paused) # blocks until :resume (or raises on :cancel/:timeout)
2340
2656
  @task_store.transition(task.id, to: :running)
2341
2657
  emit(:task_resumed, { task_id: task.id }, task: task)
@@ -2346,13 +2662,11 @@ module Insika
2346
2662
  # (does not re-save — the CheckpointStore's monotonicity would raise). It emits
2347
2663
  # NO event (:checkpoint_created is stage 8's only) and touches no side-effects.
2348
2664
  def save_initial_checkpoint(task, profile, state)
2349
- return unless @checkpoint_store.find(task.id, turn: state.turn).nil?
2350
-
2351
2665
  @checkpoint_store.save(Insika::Checkpoint.new(
2352
2666
  task_id: task.id, turn: state.turn, session_id: task.session_id,
2353
2667
  agent_id: profile.id, messages: flatten_history(state.context.history),
2354
2668
  completed_side_effects: [], created_at: Time.now.utc.iso8601
2355
- ))
2669
+ ), if_absent: true)
2356
2670
  end
2357
2671
 
2358
2672
  # GRACEFUL halt: a Middleware short-circuited with a safe reply.
@@ -2384,8 +2698,9 @@ module Insika
2384
2698
  persist_turn(task, profile, state, content, reply_origin: MessageOrigin::ENGINE,
2385
2699
  session: state.guardrail_block&.[](:source) != "edge", timing: timing)
2386
2700
  data = { task_id: task.id, content: content, usage: state.usage }
2387
- data[:timing] = timing.to_h if timing # a channel halt still measured
2388
- persist_turn_timing(task, timing)
2701
+ exposed = timing&.to_h
2702
+ data[:timing] = exposed unless exposed.nil? || exposed.empty? # a channel halt still measured
2703
+ persist_turn_timing(task, timing, profile, state.usage)
2389
2704
  emit(:task_completed, data, task: task)
2390
2705
  end
2391
2706
 
@@ -2393,9 +2708,12 @@ module Insika
2393
2708
  # The record gains `timing` once, when the turn completes; a store failure
2394
2709
  # here is swallowed — the turn is already committed and the event already
2395
2710
  # carries the number.
2396
- def persist_turn_timing(task, timing)
2711
+ # The full windows also go to ModelMetricsStore as the turn's row (agent,
2712
+ # model, provider), flag or not.
2713
+ def persist_turn_timing(task, timing, profile, usage)
2397
2714
  return unless timing
2398
2715
 
2716
+ record_turn_metrics(task, timing, profile, usage)
2399
2717
  hash = timing.to_h
2400
2718
  return if hash.empty?
2401
2719
 
@@ -2404,6 +2722,28 @@ module Insika
2404
2722
  nil
2405
2723
  end
2406
2724
 
2725
+ def record_turn_metrics(task, timing, profile, usage, status: "completed", stage: nil)
2726
+ return unless @model_metrics_store.respond_to?(:record_turn)
2727
+
2728
+ usage ||= {}
2729
+ @model_metrics_store.record_turn(task_id: task.id, row: timing.metrics.merge(
2730
+ "at" => Time.now.utc.iso8601(6), "agent" => profile.id.to_s, "status" => status,
2731
+ "stage" => stage&.to_s, "model" => usage[:model]&.to_s, "provider" => usage[:provider]&.to_s
2732
+ ).compact)
2733
+ end
2734
+
2735
+ # A failed turn keeps its row too: its total_ms runs to the failure, so a
2736
+ # turn timeout stays in the p95 instead of vanishing from it.
2737
+ def record_failed_turn(task, profile, usage, stage)
2738
+ timing = TurnTiming.current
2739
+ return unless timing && profile
2740
+
2741
+ timing.mark(:done)
2742
+ record_turn_metrics(task, timing, profile, usage, status: "failed", stage: stage)
2743
+ rescue StandardError
2744
+ nil # metrics never fail the failure path
2745
+ end
2746
+
2407
2747
  # Emits one :guardrail_flagged per flag the OutputValidator appended in
2408
2748
  # after_task (audit only — the turn already completed). Reads a plain Array off
2409
2749
  # the state, keeping the Executor decoupled from Safety. an
@@ -2436,9 +2776,27 @@ module Insika
2436
2776
  # So a long session legitimately has a Checkpoint SHORTER than the Session:
2437
2777
  # that is not drift to reconcile — it is the point. Do NOT "fix" the checkpoint
2438
2778
  # to carry the full history (it would defeat the budget) nor evict the session.
2779
+ # A customer confirmation lives for exactly one more customer message: a hold
2780
+ # an EARLIER task of this session left open, and this turn neither confirmed
2781
+ # nor cancelled, is rejected here, once the turn is durable. Fail-closed — a
2782
+ # customer who changed the subject did not agree. Best-effort like the other
2783
+ # stage-8 side writes: a store failure must not re-fail a committed turn.
2784
+ def expire_customer_confirmations(task)
2785
+ return unless @pending_action_store && task.session_id
2786
+
2787
+ expired = @pending_action_store.expire_customer_holds(session_id: task.session_id, except_task_id: task.id)
2788
+ expired.each { |pa| emit(:confirmation_expired, { pending_id: pa.id, tool: pa.tool }, task: task) }
2789
+ rescue StandardError
2790
+ nil
2791
+ end
2792
+
2439
2793
  def persist_turn(task, profile, state, content, session: true, reply_origin: nil, timing: nil)
2440
2794
  new_messages = turn_transcript(state, content, origin: command_origin(task), reply_origin: reply_origin)
2441
- transcript = flatten_history(state.context.history) + new_messages
2795
+ # Early edge refusal has no built context; retain the existing history in
2796
+ # its checkpoint while keeping the refusal out of the session transcript.
2797
+ history = state.context&.history || command_history(task) ||
2798
+ (task.session_id && @session_store.find(task.session_id)&.messages)
2799
+ transcript = flatten_history(history) + new_messages
2442
2800
 
2443
2801
  @checkpoint_store.save(Insika::Checkpoint.new(
2444
2802
  task_id: task.id, turn: state.turn + 1, session_id: task.session_id,
@@ -2449,12 +2807,26 @@ module Insika
2449
2807
  # session only when the turn is from a persisted session; one-shot/history
2450
2808
  # do not persist TO THE SESSION (but always checkpoint). `session: false` is
2451
2809
  # the edge-blocked halt (see complete_with_halt).
2452
- @session_store.append_messages(task.session_id, new_messages) if session && task.session_id
2810
+ # task_id on each persisted message ties it to the turn's traces (the
2811
+ # Studio's "why this reply" reads it); history replay ignores the key.
2812
+ if session && task.session_id
2813
+ @session_store.append_messages(task.session_id, new_messages.map { |m| m.merge("task_id" => task.id) },
2814
+ agent: profile.id)
2815
+ end
2816
+
2817
+ if (shared = @shared_turns[task.id])
2818
+ if @pending_action_store && task.session_id
2819
+ @pending_action_store.expire_customer_holds(session_id: task.session_id, except_task_id: task.id)
2820
+ end
2821
+ pending = @pending_action_store && task.session_id && !@pending_action_store.open_for_session(task.session_id).empty?
2822
+ @shared_conversations.complete(task: task, messages: new_messages, pending_approval: !!pending,
2823
+ output_parts: state.output_parts, attachments: delivery_attachments(state))
2824
+ shared[:committed] = true
2825
+ emit(:content, { delta: content }, task: task) unless content.empty?
2826
+ end
2453
2827
 
2454
- # finish_execution (closes the Execution) BEFORE transition(:completed) —
2455
- # transition without error: does not close, so the finish is needed here.
2456
- @task_store.finish_execution(task.id, outcome: :completed)
2457
- @task_store.transition(task.id, to: :completed)
2828
+ # closes the Execution and moves to :completed in one write.
2829
+ @task_store.complete_execution(task.id, outcome: :completed)
2458
2830
  # prune is best-effort cleanup: a failure here must NOT re-fail an
2459
2831
  # already-committed turn (the task is already :completed and durable).
2460
2832
  # Swallow.
@@ -2480,13 +2852,13 @@ module Insika
2480
2852
  # path — the user already has the answer above. Same terminal hook,
2481
2853
  # next door to the other two, for the same reason: it fires for a fresh
2482
2854
  # turn and a recovered one.
2483
- finalize_knowledge_extraction(task, profile, new_messages)
2855
+ finalize_knowledge_extraction(task, profile, new_messages, state)
2484
2856
 
2485
2857
  # in-session compaction (RFC-0044): when the uncompacted
2486
2858
  # transcript crossed the threshold this turn, summarize the old prefix
2487
2859
  # with the cheap model and move the boundary — off the critical path,
2488
2860
  # same terminal hook, same best-effort discipline as the other three.
2489
- finalize_compaction(task, profile)
2861
+ finalize_compaction(task, profile, state.chat)
2490
2862
  end
2491
2863
 
2492
2864
  # Records the answer in the outbox and dispatches it. The discriminator is the
@@ -2515,7 +2887,11 @@ module Insika
2515
2887
 
2516
2888
  # the hoarded evidence attachments ride the channel delivery
2517
2889
  # (additive outbox payload key — the channel contract widens, nothing breaks).
2518
- attachments = state.respond_to?(:evidence_attachments) ? state.evidence_attachments : nil
2890
+ # When the model SELECTED cards with a presentation tool this turn, the
2891
+ # selection is what rides — in call order, each card stamped with the
2892
+ # component and title of the call that picked it. No selection = every
2893
+ # hoarded card, as before.
2894
+ attachments = delivery_attachments(state)
2519
2895
  deliveries = @channel_delivery.record_balloons(
2520
2896
  task: task, channel_id: channel_id, content: content,
2521
2897
  progressive: @channel_delivery.progressive?(channel_id),
@@ -2529,6 +2905,17 @@ module Insika
2529
2905
  nil
2530
2906
  end
2531
2907
 
2908
+ def delivery_attachments(state)
2909
+ presentations = state.respond_to?(:presentations) ? Array(state.presentations) : []
2910
+ if presentations.empty?
2911
+ return state.respond_to?(:evidence_attachments) ? state.evidence_attachments : nil
2912
+ end
2913
+
2914
+ presentations.flat_map do |p|
2915
+ Array(p["items"]).map { |item| item.merge("component" => p["component"], "title" => p["title"]).compact }
2916
+ end
2917
+ end
2918
+
2532
2919
  # Turns whose combined transcript slice is this trivially short skip
2533
2920
  # extraction entirely ("ok thanks" exchanges) — no new config surface,
2534
2921
  # just avoids a wasted utility-model call.
@@ -2541,22 +2928,27 @@ module Insika
2541
2928
  # boot sweep), a child of the turn supervisor when serving (survives the
2542
2929
  # request's own disconnect). Best-effort: any failure is swallowed here,
2543
2930
  # never re-fails an already-committed turn.
2544
- def finalize_knowledge_extraction(task, profile, new_messages)
2931
+ def finalize_knowledge_extraction(task, profile, new_messages, state = nil)
2545
2932
  return unless @knowledge_store
2933
+ return if simulated_session?(task.session_id)
2546
2934
 
2547
2935
  config = Coercion.deep_stringify(profile.knowledge)
2548
2936
  return unless config && Coercion.truthy?(config["extract"])
2549
2937
  return if knowledge_transcript(new_messages).length < KNOWLEDGE_MIN_CHARS
2550
2938
 
2551
- extractor = Knowledge::ExtractorFactory.build(config, utility_model: utility_model)
2939
+ extractor = Knowledge::ExtractorFactory.build(config, utility_model: utility_model,
2940
+ llm: llm_operation_context(state, nil, operation: "knowledge_extract"))
2552
2941
  return unless extractor
2553
2942
 
2554
2943
  # Same resolved model as the extractor — a deployment names one
2555
2944
  # knowledge model, not two. nil consolidator (no model resolvable) is
2556
2945
  # still meaningful: write_concept's conservative default.
2557
- consolidator = Knowledge::ConsolidatorFactory.build(config, utility_model: utility_model)
2946
+ consolidator = Knowledge::ConsolidatorFactory.build(config, utility_model: utility_model,
2947
+ llm: llm_operation_context(state, nil, operation: "knowledge_consolidate"))
2558
2948
 
2559
- run = lambda { run_knowledge_extraction(task, profile, config, new_messages, extractor, consolidator) }
2949
+ context = state&.context
2950
+ agent_brief = context.respond_to?(:system_identity) ? context.system_identity : nil
2951
+ run = lambda { run_knowledge_extraction(task, profile, config, new_messages, extractor, consolidator, agent_brief) }
2560
2952
  return run.call unless @supervised
2561
2953
 
2562
2954
  turn_parent.async do |t|
@@ -2565,10 +2957,17 @@ module Insika
2565
2957
  end
2566
2958
  end
2567
2959
 
2568
- def run_knowledge_extraction(task, profile, config, new_messages, extractor, consolidator)
2569
- prompt = knowledge_prompt(config, new_messages)
2960
+ def run_knowledge_extraction(task, profile, config, new_messages, extractor, consolidator, agent_brief = nil)
2961
+ prompt = knowledge_prompt(config, new_messages, agent_brief)
2570
2962
  result = extractor.extract(prompt: prompt)
2571
2963
  result[:concepts].each do |concept|
2964
+ if profile.shared_conversations
2965
+ redacted, = Safety::Detectors.redact(concept['body'].to_s)
2966
+ @shared_conversations.propose_memory(task:task,id:concept['name'],kind:'knowledge',
2967
+ value:Knowledge.first_sighting(concept,redacted,rebuild_command(task).payload.dig('shared_conversation','conversation_id')))
2968
+ emit(:knowledge_conflict, {name:concept['name'],agent:profile.id}, task:task)
2969
+ next
2970
+ end
2572
2971
  outcome = Knowledge.write_concept(
2573
2972
  store: @knowledge_store, agent_id: profile.id, concept: concept, session_id: task.session_id,
2574
2973
  tenant: task_tenant(task), consolidator: consolidator
@@ -2591,26 +2990,32 @@ module Insika
2591
2990
  end
2592
2991
  end
2593
2992
 
2594
- def knowledge_prompt(config, new_messages)
2993
+ # Transcripts drop tool results (product cards, search hits), so without the
2994
+ # agent's own identity the extractor guesses the domain: "pode ser importado"
2995
+ # about a perfume became "imported vehicles" for a beauty store.
2996
+ KNOWLEDGE_BRIEF_CHARS = 1500
2997
+
2998
+ def knowledge_prompt(config, new_messages, agent_brief = nil)
2595
2999
  base = Coercion.presence(config["prompt"]) || Knowledge::DEFAULT_PROMPT
3000
+ # ponytail: the head of the identity prompt names the store in our packs; a pack whose prompt
3001
+ # opens elsewhere needs a `knowledge.prompt` that states the domain.
3002
+ brief = Coercion.presence(agent_brief.to_s[0, KNOWLEDGE_BRIEF_CHARS])
3003
+ agent = brief ? "\n## The agent (read for its business domain only)\n\n#{brief}\n" : ""
2596
3004
  <<~PROMPT
2597
3005
  #{base.rstrip}
2598
-
3006
+ #{agent}
2599
3007
  ## The conversation
2600
3008
 
2601
3009
  #{knowledge_transcript(new_messages)}
2602
3010
  PROMPT
2603
3011
  end
2604
3012
 
2605
- # Redacted (RFC's PII rule applies to what reaches the model too, not
2606
- # just what gets persisted).
2607
- def knowledge_transcript(new_messages)
2608
- redacted, = Insika::Safety::Detectors.redact(
2609
- new_messages.each_with_index.map { |m, i| "[#{i}] #{m['role'] || m[:role]}: #{m['content'] || m[:content]}" }
2610
- .join("\n")
2611
- )
2612
- redacted
2613
- end
3013
+ # Synthetic traffic must not teach the production agent (SessionStore.synthetic?).
3014
+ def simulated_session?(session_id) = Insika::SessionStore.synthetic?(session_id)
3015
+
3016
+ # Only what people said, PII-redacted — a `role: tool` message is
3017
+ # third-party text and never a source of a learned concept.
3018
+ def knowledge_transcript(new_messages) = Insika::SpokenTranscript.render(new_messages)
2614
3019
 
2615
3020
  def utility_model
2616
3021
  return nil unless @settings_store
@@ -2618,6 +3023,14 @@ module Insika
2618
3023
  @settings_store.get["utility_model"]
2619
3024
  end
2620
3025
 
3026
+ # `Settings fencing.max_chars`, the per-leaf cap the envelope applies for a
3027
+ # fenced agent. One settings read per fenced turn, like the queue policy.
3028
+ def fence_max_chars
3029
+ config = @settings_store && Coercion.deep_stringify(@settings_store.get["fencing"])
3030
+ n = config && config["max_chars"].to_i
3031
+ n && n.positive? ? n : Insika::Fence::DEFAULT_MAX_CHARS
3032
+ end
3033
+
2621
3034
  # in-session compaction (RFC-0044). Platform-gated
2622
3035
  # (Settings compaction.enabled — parity when off), planned over the
2623
3036
  # POST-append transcript (persist_turn already wrote this turn's messages),
@@ -2625,7 +3038,7 @@ module Insika
2625
3038
  # finalize_knowledge_extraction: inline when non-supervised, a child of the
2626
3039
  # turn supervisor when serving. Best-effort: any failure leaves the session
2627
3040
  # record untouched and the next turn re-plans.
2628
- def finalize_compaction(task, profile)
3041
+ def finalize_compaction(task, profile, chat = nil)
2629
3042
  return unless task.session_id && @settings_store
2630
3043
 
2631
3044
  config = Coercion.deep_stringify(@settings_store.get["compaction"])
@@ -2635,16 +3048,29 @@ module Insika
2635
3048
  return unless session
2636
3049
 
2637
3050
  state = session.respond_to?(:compaction) ? session.compaction : nil
3051
+ native = config["mode"] == "native"
3052
+ binding = Compaction.native_binding(chat) if native
3053
+ return if native && !binding
3054
+
3055
+ # A portable summary must start from the original prefix, not an opaque
3056
+ # native marker, when the operator switches compaction modes.
3057
+ state = nil if !native && state && state["native"]
2638
3058
  plan = Insika::Compaction.plan(messages: session.messages, state: state, config: config)
2639
3059
  return unless plan
2640
3060
 
2641
3061
  # compaction.model -> platform utility_model -> inert (never a guess);
2642
3062
  # `insika doctor` warns on enabled-with-no-model. @llm rides along so a
2643
3063
  # deployment (or spec) that injects its LLM seam covers this call too.
2644
- summarizer = Compaction::SummarizerFactory.build(config, utility_model: utility_model, llm: @llm)
2645
- return unless summarizer
3064
+ if native
3065
+ run = lambda { run_native_compaction(task, profile, session, plan, chat, binding) }
3066
+ else
3067
+ llm = @llm || (defined?(RubyLLM) && RubyLLM)
3068
+ llm = instrumented_llm(task, nil, operation: "compaction", agent: profile.id) if llm
3069
+ summarizer = Compaction::SummarizerFactory.build(config, utility_model: utility_model, llm: llm)
3070
+ return unless summarizer
2646
3071
 
2647
- run = lambda { run_compaction(task, profile, session, state, plan, config, summarizer) }
3072
+ run = lambda { run_compaction(task, profile, session, state, plan, config, summarizer) }
3073
+ end
2648
3074
  return run.call unless @supervised
2649
3075
 
2650
3076
  turn_parent.async do |t|
@@ -2653,6 +3079,27 @@ module Insika
2653
3079
  end
2654
3080
  end
2655
3081
 
3082
+ def run_native_compaction(task, profile, session, plan, source, binding)
3083
+ # Rebuild from persisted, filtered messages; never copy the live chat's raw output.
3084
+ chat = source.context.chat(model: source.model.id, provider: binding.fetch("provider"),
3085
+ protocol: :responses, assume_model_exists: true)
3086
+ @chat_builder.seed_history(chat, session.messages.take(plan.upto))
3087
+ message = chat.compact
3088
+ raw = message.raw_content
3089
+ return unless Compaction.native_payload?(raw)
3090
+
3091
+ native = { "binding" => binding, "message" => {
3092
+ "role" => "assistant", "content" => "", "raw_content" => raw
3093
+ } }
3094
+ updated = @session_store.set_compaction(task.session_id, native: native,
3095
+ upto: plan.upto, model: binding.fetch("model"))
3096
+ emit(:context_compacted, { task_id: task.id, agent: profile.id, from: 0, upto: plan.upto,
3097
+ messages: plan.upto, runs: updated.compaction["runs"], model: binding.fetch("model"),
3098
+ usage: usage_of(chat) }, task: task)
3099
+ rescue StandardError
3100
+ nil # A failed compaction cannot fail the already-committed turn.
3101
+ end
3102
+
2656
3103
  def run_compaction(task, profile, session, state, plan, config, summarizer)
2657
3104
  prompt = Insika::Compaction.prompt(messages: session.messages, plan: plan,
2658
3105
  previous: state && state["summary"],
@@ -2712,28 +3159,39 @@ module Insika
2712
3159
  def turn_transcript(state, content, origin: nil, reply_origin: nil)
2713
3160
  recorded = recorded_turn_messages(state)
2714
3161
  if recorded.empty?
2715
- return [MessageOrigin.stamp({ "role" => "user", "content" => state.message.to_s }, origin),
3162
+ return [stamp_guardrail_flags(MessageOrigin.stamp({ "role" => "user", "content" => ask_text(state) }, origin), state),
2716
3163
  MessageOrigin.stamp({ "role" => "assistant", "content" => content.to_s }, reply_origin)]
2717
3164
  end
2718
3165
 
2719
- recorded[0] = MessageOrigin.stamp(recorded[0], origin) if recorded[0]["role"] == "user"
3166
+ recorded[0] = stamp_guardrail_flags(MessageOrigin.stamp(recorded[0], origin), state) if recorded[0]["role"] == "user"
2720
3167
  recorded[-1] = recorded[-1].merge("content" => content.to_s) if recorded.last["role"] == "assistant"
2721
3168
  recorded
2722
3169
  end
2723
3170
 
3171
+ # Flags raised on the INCOMING message (the input guardrail runs before the
3172
+ # model; output flags come after persist) ride on that message, so the
3173
+ # transcript shows them after the live event stream is gone. Never sent to the
3174
+ # model: history replay reads role/content/tool_calls only.
3175
+ def stamp_guardrail_flags(message, state)
3176
+ flags = Array(state.guardrail_flags)
3177
+ return message if flags.empty?
3178
+
3179
+ message.merge("guardrail_flags" => flags.map { |f| f.transform_keys(&:to_s) })
3180
+ end
3181
+
2724
3182
  # Slices the chat's messages added DURING this turn and serializes them.
2725
3183
  # [] when there is no recorded transcript (baseline nil / no #messages).
2726
- def recorded_turn_messages(state)
2727
- chat = state.chat
3184
+ def recorded_turn_messages(state, chat: state.chat)
2728
3185
  baseline = state.chat_baseline
2729
3186
  return [] unless chat && baseline && chat.respond_to?(:messages)
2730
3187
 
2731
- Array(chat.messages).drop(baseline).filter_map { |m| serialize_chat_message(m) }
3188
+ Array(chat.messages).drop(baseline).filter_map { |m| serialize_chat_message(m, clip: !(state.task && @shared_turns.key?(state.task.id))) }
2732
3189
  end
2733
3190
 
2734
3191
  # A RubyLLM::Message (duck-typed) -> string-keyed Hash. Assistant carries
2735
3192
  # "tool_calls" only when present; tool carries "tool_call_id" + a clipped content.
2736
- def serialize_chat_message(msg)
3193
+ # Do not persist raw_content: native replay would bypass redaction and clipping.
3194
+ def serialize_chat_message(msg, clip: true)
2737
3195
  role = msg_field(msg, :role).to_s
2738
3196
  content = msg_field(msg, :content).to_s
2739
3197
  case role
@@ -2744,7 +3202,7 @@ module Insika
2744
3202
  h
2745
3203
  when "tool"
2746
3204
  { "role" => "tool", "tool_call_id" => msg_field(msg, :tool_call_id).to_s,
2747
- "content" => clip_tool_content(content) }
3205
+ "content" => clip ? clip_tool_content(content) : content }
2748
3206
  else
2749
3207
  { "role" => role, "content" => content }
2750
3208
  end
@@ -2793,6 +3251,7 @@ module Insika
2793
3251
  require_relative "tools/load_knowledge"
2794
3252
  require_relative "tools/tool_search"
2795
3253
  require_relative "tools/remember"
3254
+ require_relative "tools/confirm_pending"
2796
3255
  require_relative "tools/subagent"
2797
3256
  require_relative "tools/subagents"
2798
3257
  require_relative "tools/stuck_signal"
@@ -2807,16 +3266,17 @@ module Insika
2807
3266
  # enforced, fallback chain resolved. Kept on the state for telemetry (usage).
2808
3267
  selection = @model_resolver.resolve(profile: profile, session: state.session)
2809
3268
  state.model_selection = selection
2810
- build_chat(selection, selection)
3269
+ build_chat(selection, selection, state: state)
2811
3270
  end
2812
3271
 
2813
3272
  # The gem boundary: one chat for a model selection (the resolved primary or
2814
3273
  # a WS3 fallback node). The primary's generation params apply to the whole
2815
3274
  # chain (params_source: ModelSelection#apply_params).
2816
- def build_chat(selection, params_source)
3275
+ def build_chat(selection, params_source, state: nil)
2817
3276
  model = selection.respond_to?(:model) ? selection.model : selection[:model]
2818
3277
  provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
2819
- chat = (@llm || RubyLLM).chat(
3278
+ @llm_refresh&.call
3279
+ chat = llm_operation_context(state, model).chat(
2820
3280
  model: model,
2821
3281
  provider: provider,
2822
3282
  assume_model_exists: !provider.nil?
@@ -2825,6 +3285,62 @@ module Insika
2825
3285
  chat
2826
3286
  end
2827
3287
 
3288
+ # `operation` labels every request this context makes in the task's traces and
3289
+ # model metrics: "chat" for the answer, knowledge_* for the post-turn calls, so
3290
+ # the Models page can tell the reply's cost from the learning's.
3291
+ def llm_operation_context(state, model, operation: "chat")
3292
+ return @llm || RubyLLM unless state.respond_to?(:task) && state.task
3293
+
3294
+ instrumented_llm(state.task, model, operation: operation, turn: state.turn,
3295
+ agent: state.respond_to?(:profile) ? state.profile&.id : nil)
3296
+ end
3297
+
3298
+ # Every request a task makes, whatever the caller: rows carry the agent, so
3299
+ # the Models page filters by it.
3300
+ def instrumented_llm(task, model, operation:, turn: nil, agent: nil)
3301
+ source = @llm || RubyLLM
3302
+ return source unless source.respond_to?(:config)
3303
+
3304
+ require_relative "telemetry/ruby_llm_instrumenter"
3305
+ config = source.config.dup
3306
+ extra = { "turn" => turn, "agent" => agent&.to_s }.compact
3307
+ config.instrumenter = Telemetry::RubyLLMInstrumenter.new(
3308
+ delegate: config.instrumenter, operation: operation, model: model, pricing: @pricing,
3309
+ emit: ->(type, data) { emit(type, data.merge(extra), task: task) }
3310
+ )
3311
+ RubyLLM::Context.new(config)
3312
+ end
3313
+
3314
+ # A turn's LLM diagnostics in one batch per store, instead of one transaction
3315
+ # per provider event: written when the turn ends (however it ends, after the
3316
+ # customer already has the answer) and before it suspends for a human.
3317
+ def write_llm_diagnostics(task, entries)
3318
+ return if entries.nil? || entries.empty?
3319
+
3320
+ [@llm_trace_store, @model_metrics_store].each do |store|
3321
+ next unless store
3322
+
3323
+ if store.respond_to?(:record_many)
3324
+ store.record_many(task_id: task.id, entries: entries)
3325
+ else
3326
+ entries.each { |entry| store.record(task_id: task.id, entry: entry) }
3327
+ end
3328
+ rescue StandardError
3329
+ # Diagnostics must never fail the turn.
3330
+ end
3331
+ end
3332
+
3333
+ # Writes what the running turn buffered so far and keeps buffering: a turn
3334
+ # about to wait (approval, pause) may wait for hours, and its cost so far
3335
+ # must be visible and survive a crash meanwhile.
3336
+ def flush_llm_diagnostics(task)
3337
+ entries = @llm_buffers[task.id]
3338
+ return if entries.nil? || entries.empty?
3339
+
3340
+ @llm_buffers[task.id] = []
3341
+ write_llm_diagnostics(task, entries)
3342
+ end
3343
+
2828
3344
  # Single emitter: an Event with meta and a monotonic seq per task. @seqs is not
2829
3345
  # cleared at the end of the task — the resume (new Execution) continues the
2830
3346
  # numbering (reliable replay). A task WITH a tenant (WS1) tags every event it
@@ -2832,10 +3348,28 @@ module Insika
2832
3348
  # event without a task has no tenant and never matches a tenant stream);
2833
3349
  # absent tenant -> the meta is byte-identical to before.
2834
3350
  def emit(type, data, task:)
3351
+ # Required mode releases only the accepted final answer after its durable ack.
3352
+ if (shared = @shared_turns[task.id]) && !shared[:committed]
3353
+ return if %i[content intermediate thinking workflow_completed].include?(type)
3354
+ end
2835
3355
  meta = { task_id: task.id, session_id: task.session_id,
2836
- seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601 }
3356
+ seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601(6) }
2837
3357
  tenant = task_tenant(task)
2838
3358
  meta[:tenant] = tenant unless tenant.nil?
3359
+ if type == :llm_request || type == :llm_usage
3360
+ entry = data.merge("type" => type.to_s, "at" => meta[:at])
3361
+ if (buffer = @llm_buffers[task.id])
3362
+ buffer << entry # a running turn writes them all at its end (#flush_llm_diagnostics)
3363
+ else
3364
+ [@llm_trace_store, @model_metrics_store].each do |store|
3365
+ begin
3366
+ store&.record(task_id: task.id, entry: entry)
3367
+ rescue StandardError
3368
+ # Recorders fail independently; diagnostics must not interrupt the model.
3369
+ end
3370
+ end
3371
+ end
3372
+ end
2839
3373
  @event_stream.emit(Insika::Event.new(type: type, data: data, meta: meta))
2840
3374
  end
2841
3375