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 "net/http"
4
4
  require "json"
5
5
  require "uri"
6
+ require "open3"
6
7
  require_relative "assertions"
7
8
 
8
9
  module Insika
@@ -22,6 +23,11 @@ module Insika
22
23
  # different facts, and a budget that confuses them stops being a budget.
23
24
  TurnOutcome = Struct.new(:result, :ttfb, :total, :usage, keyword_init: true)
24
25
 
26
+ # The deployment answered the seed with a refusal (403: `evals.seeding` is off
27
+ # there). Its own class so the Runner can SKIP the case with the reason — a
28
+ # different fact from a seed that failed (409, 5xx, network), which fails it.
29
+ class SeedRefused < Insika::Error; end
30
+
25
31
  # Pure reduction of the /v1/responses SSE stream. Kept separate from the HTTP so
26
32
  # it's testable offline with canned frames (server/responses.rb is the producer).
27
33
  module Sse
@@ -47,28 +53,67 @@ module Insika
47
53
 
48
54
  # [payload] -> { output_text:, tool_calls:, usage:, error: }. Maps the Responses
49
55
  # frames (see server/responses.rb#frame_for): text deltas accumulate; each
50
- # function_call item contributes a tool NAME (this stream carries no per-tool
51
- # status — that lives in the ToolTraceStore, an in-process enrichment); a
52
- # `response.failed` sets the turn error.
56
+ # function_call `added` item opens a tool call (name + arguments); its `done`
57
+ # item closes it with how the call ended (status ok/error/blocked + the gate
58
+ # that held it). An older deployment sends `added` only, and the entries keep
59
+ # `status: nil` — the shape widened, nothing moved. A `response.failed` sets
60
+ # the turn error.
53
61
  def reduce(payloads)
54
62
  text = +""
55
63
  tools = []
64
+ by_id = {}
65
+ ui = []
56
66
  usage = nil
57
67
  error = nil
58
68
  payloads.each do |o|
59
69
  case o["type"]
60
70
  when "response.output_text.delta"
61
71
  text << o["delta"].to_s
72
+ when "insika.ui"
73
+ # A presentation tool's selection — the component and how many cards it
74
+ # showed. The `ui_components`/`no_ui` graders read this.
75
+ ui << { "component" => o["component"].to_s, "count" => o["count"].to_i }
62
76
  when "response.output_item.added"
63
77
  item = o["item"] || {}
64
- tools << { "name" => item["name"].to_s, "status" => nil } if item["type"] == "function_call"
78
+ next unless item["type"] == "function_call"
79
+
80
+ entry = { "name" => item["name"].to_s, "status" => nil }
81
+ (args = arguments_of(item["arguments"])) && (entry["arguments"] = args)
82
+ tools << entry
83
+ (id = item["call_id"]) && (by_id[id] = entry)
84
+ when "response.output_item.done"
85
+ item = o["item"] || {}
86
+ next unless item["type"] == "function_call"
87
+
88
+ # Paired by call_id. An older engine's frames carry none: then the FIRST
89
+ # still-open call of that name (a batch runs concurrently and its `done`
90
+ # frames arrive in completion order). No `added` at all = a system tool
91
+ # the engine reports the end of but never announced (load_skill): not a
92
+ # tool call here, exactly as it is not one in the in-process transport.
93
+ entry = by_id[item["call_id"]] || tools.find { |t| t["name"] == item["name"].to_s && t["status"].nil? }
94
+ next unless entry
95
+
96
+ entry["status"] = item["status"].to_s
97
+ entry["gate"] = item["gate"].to_s if item["gate"]
65
98
  when "response.completed"
66
99
  usage = o.dig("response", "usage")
67
100
  when "response.failed"
68
101
  error = o.dig("response", "error", "message") || "response.failed"
69
102
  end
70
103
  end
71
- { output_text: text, tool_calls: tools, usage: usage, error: error }
104
+ { output_text: text, tool_calls: tools, ui: ui, usage: usage, error: error }
105
+ end
106
+
107
+ # The item's `arguments` is a JSON string on the wire (the OpenAI shape); the
108
+ # graders want the Hash. Anything unparseable stays the raw string — the call
109
+ # happened, and a report should still show what it saw. nil when absent.
110
+ def arguments_of(raw)
111
+ return nil if raw.nil?
112
+ return raw unless raw.is_a?(String)
113
+
114
+ JSON.parse(raw)
115
+ rescue JSON::ParserError
116
+ raw
72
117
  end
73
118
  end
74
119
 
@@ -160,14 +205,50 @@ module Insika
160
205
 
161
206
  reduced = Sse.reduce(Sse.payloads(buffer))
162
207
  TurnOutcome.new(
163
- result: TurnResult.new(output_text: reduced[:output_text],
164
- tool_calls: reduced[:tool_calls], error: reduced[:error]),
208
+ result: TurnResult.new(output_text: reduced[:output_text], tool_calls: reduced[:tool_calls],
209
+ ui: reduced[:ui], error: reduced[:error]),
165
210
  ttfb: ttfb, total: (mono - t0) * 1000.0, usage: reduced[:usage]
166
211
  )
167
212
  rescue StandardError => e
168
213
  failure(e.class.to_s, t0)
169
214
  end
170
215
 
216
+ # Loads a case's `state:` into the conversation BEFORE its first turn —
217
+ # `POST /v1/conversations/:conv/seed`, the same Bearer as the turn, so the
218
+ # seeded session is the one the turn continues. The body is the state mapping
219
+ # (+ `customer` when the turns will carry one). A 403 is the deployment
220
+ # refusing to seed (`evals.seeding` off) -> SeedRefused, which the Runner
221
+ # turns into a skip; anything else that is not 2xx is an error that fails the
222
+ # case — a seed that did not land must never let the case run empty and pass.
223
+ def seed(conv, state, customer: nil)
224
+ uri = URI.join("#{@base}/", "v1/conversations/#{URI.encode_www_form_component(conv)}/seed")
225
+ req = Net::HTTP::Post.new(uri)
226
+ req["Authorization"] = "Bearer #{@token}"
227
+ req["Content-Type"] = "application/json"
228
+ body = Insika::Coercion.deep_stringify(state)
229
+ body["customer"] = customer if customer
230
+ req.body = JSON.generate(body)
231
+
232
+ http = Net::HTTP.new(uri.host, uri.port)
233
+ http.use_ssl = uri.scheme == "https"
234
+ http.read_timeout = @timeout
235
+ http.open_timeout = 10
236
+ res = http.start { http.request(req) }
237
+ code = res.code.to_i
238
+ return true if code.between?(200, 299)
239
+
240
+ message = begin
241
+ JSON.parse(res.body.to_s).dig("error", "message")
242
+ rescue JSON::ParserError
243
+ nil
244
+ end
245
+ raise SeedRefused, "HTTP 403#{" #{message}" if message}" if code == 403
246
+
247
+ raise Insika::Error, "HTTP #{code}#{" #{message}" if message}"
248
+ rescue SystemCallError, IOError, Net::OpenTimeout, Net::ReadTimeout => e
249
+ raise Insika::Error, e.class.to_s
250
+ end
251
+
171
252
  private
172
253
 
173
254
  def failure(msg, t0)
@@ -199,16 +280,18 @@ module Insika
199
280
 
200
281
  def turn(agent:, conv:, message:)
201
282
  t0 = mono
202
- sub = @event_stream&.subscribe(types: [:tool_call])
283
+ sub = @event_stream&.subscribe(types: %i[tool_call ui])
203
284
  begin
204
285
  text = @runtime.chat(message, session_id: conv, agent: agent)
286
+ tools, ui = drain(sub)
205
287
  TurnOutcome.new(
206
- result: TurnResult.new(output_text: text.to_s, tool_calls: drain_tools(sub), error: nil),
288
+ result: TurnResult.new(output_text: text.to_s, tool_calls: tools, ui: ui, error: nil),
207
289
  ttfb: nil, total: (mono - t0) * 1000.0, usage: nil
208
290
  )
209
291
  rescue Insika::Error => e
292
+ tools, ui = drain(sub)
210
293
  TurnOutcome.new(
211
- result: TurnResult.new(output_text: "", tool_calls: drain_tools(sub), error: e.message),
294
+ result: TurnResult.new(output_text: "", tool_calls: tools, ui: ui, error: e.message),
212
295
  ttfb: nil, total: (mono - t0) * 1000.0, usage: nil
213
296
  )
214
297
  ensure
@@ -216,14 +299,233 @@ module Insika
216
299
  end
217
300
  end
218
301
 
302
+ # In-process seeding: the SAME `seed_session` command the HTTP route dispatches,
303
+ # on the graph's own bus — no route, no gate (there is no tenant token to
304
+ # protect here; whoever holds the graph already holds everything). A runtime
305
+ # that exposes no graph cannot seed, and says so as an error the Runner fails
306
+ # the case with.
307
+ def seed(conv, state, customer: nil)
308
+ bus = @runtime.graph.bus if @runtime.respond_to?(:graph) && @runtime.graph
309
+ raise Insika::Error, "transport cannot seed state: the runtime exposes no graph" if bus.nil?
310
+
311
+ payload = { id: conv, state: state }
312
+ payload[:customer] = customer if customer
313
+ bus.dispatch(Insika::Command.build(:seed_session, payload, transport: :internal))
314
+ end
315
+
316
+ private
317
+
318
+ # The same shapes an HTTP replay produces: tools as [{ "name" =>, "status" => nil }]
319
+ # (the stream carries no per-tool status — that lives in the trace store), ui as
320
+ # [{ "component" =>, "count" => }]. -> [tools, ui]
321
+ def drain(sub)
322
+ return [[], []] if sub.nil?
323
+
324
+ events = sub.drain_nonblocking
325
+ tools = events.select { |ev| ev.type == :tool_call }
326
+ .map { |ev| { "name" => ev.data[:name].to_s, "status" => nil } }
327
+ ui = events.select { |ev| ev.type == :ui }
328
+ .map { |ev| { "component" => ev.data[:component].to_s, "count" => ev.data[:count].to_i } }
329
+ [tools, ui]
330
+ end
331
+ end
332
+
333
+ # Drives a turn through an EXTERNAL agent runtime — a rival harness in a
334
+ # container — so the same corpus can be replayed against something that is not
335
+ # Insika and the answers land in the same TurnResult the graders already read.
336
+ #
337
+ # The protocol is one JSON object each way, per turn:
338
+ #
339
+ # in (stdin): {"agent": "...", "conv": "...", "message": "..."}
340
+ # out (stdout): {"output_text": "...", "tool_calls": [...], "error": null,
341
+ # "usage": {...}}
342
+ #
343
+ # `command` is an argv array, never a shell string: the message is customer text
344
+ # and must not be able to reach a shell. The adapter that speaks this protocol
345
+ # lives with the harness under test, not here — every rival has its own idea of
346
+ # what a turn is, and that translation is exactly what a bench entry costs.
347
+ #
348
+ # A harness whose adapter cannot report which tools were called says so ONCE, at
349
+ # construction (`reports_tool_calls: false`), and the Runner skips its tool-shaped
350
+ # graders with the reason. Otherwise a missing `tool_calls` is read as "this turn
351
+ # called nothing", which is a claim, not an absence.
352
+ #
353
+ # Timeout kills the child process; a container it started keeps running, which is
354
+ # the compose file's business (`--rm` and a per-run project name) rather than
355
+ # something to reimplement here.
356
+ class DriverTransport
357
+ def initialize(command:, timeout: 300, reports_tool_calls: true, env: {})
358
+ raise ArgumentError, "command must be an argv array" unless command.is_a?(Array) && !command.empty?
359
+
360
+ @command = command.map(&:to_s)
361
+ @timeout = timeout
362
+ @reports_tool_calls = reports_tool_calls
363
+ @env = env.transform_keys(&:to_s).transform_values(&:to_s)
364
+ end
365
+
366
+ def reports_tool_calls? = @reports_tool_calls
367
+
368
+ def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
369
+
370
+ def turn(agent:, conv:, message:)
371
+ t0 = mono
372
+ out, err, status = run_driver(JSON.generate(agent: agent, conv: conv, message: message))
373
+ return failure(err, t0) if status.nil? || !status.success?
374
+
375
+ answer = last_json_object(out)
376
+ return failure("driver wrote no JSON object#{" (stderr: #{tail(err)})" if err}", t0) if answer.nil?
377
+
378
+ TurnOutcome.new(
379
+ result: TurnResult.new(output_text: answer["output_text"].to_s,
380
+ tool_calls: normalize_tool_calls(answer["tool_calls"]),
381
+ ui: [], error: presence(answer["error"])),
382
+ ttfb: nil, total: (mono - t0) * 1000.0, usage: answer["usage"]
383
+ )
384
+ rescue StandardError => e
385
+ failure("#{e.class}: #{e.message}", t0)
386
+ end
387
+
388
+ private
389
+
390
+ # -> [stdout, stderr-or-reason, status]. status nil = it never finished.
391
+ def run_driver(input)
392
+ Open3.popen3(@env, *@command) do |stdin, stdout, stderr, wait_thr|
393
+ stdin.write(input)
394
+ stdin.close
395
+ out = +""
396
+ err = +""
397
+ readers = [Thread.new { out << stdout.read }, Thread.new { err << stderr.read }]
398
+
399
+ if wait_thr.join(@timeout).nil?
400
+ kill(wait_thr.pid)
401
+ readers.each(&:kill)
402
+ return [out, "driver timed out after #{@timeout}s", nil]
403
+ end
404
+
405
+ readers.each(&:join)
406
+ status = wait_thr.value
407
+ [out, status.success? ? err : "exit #{status.exitstatus}#{" — #{tail(err)}" if presence(err)}", status]
408
+ end
409
+ end
410
+
411
+ def kill(pid)
412
+ Process.kill("KILL", pid)
413
+ rescue Errno::ESRCH, Errno::EPERM
414
+ nil
415
+ end
416
+
417
+ # The driver's answer is the LAST JSON object it printed: an adapter that logs
418
+ # to stdout is the normal case, and refusing to read past its chatter would
419
+ # turn every such harness into an entry-gate failure it does not deserve.
420
+ def last_json_object(out)
421
+ out.to_s.each_line.reverse_each do |line|
422
+ parsed = begin
423
+ JSON.parse(line)
424
+ rescue JSON::ParserError
425
+ nil
426
+ end
427
+ return parsed if parsed.is_a?(Hash)
428
+ end
429
+ nil
430
+ end
431
+
432
+ # Adapters report tool calls in whichever shape their harness has: a bare name,
433
+ # or an object. Both become the shape the graders read.
434
+ def normalize_tool_calls(raw)
435
+ Array(raw).filter_map do |call|
436
+ case call
437
+ when String then { "name" => call, "status" => nil }
438
+ when Hash
439
+ name = (call["name"] || call[:name]).to_s
440
+ next if name.empty?
441
+
442
+ entry = { "name" => name, "status" => presence(call["status"]) }
443
+ (args = call["arguments"]) && (entry["arguments"] = args)
444
+ (gate = presence(call["gate"])) && (entry["gate"] = gate)
445
+ entry
446
+ end
447
+ end
448
+ end
449
+
450
+ def presence(value)
451
+ s = value.to_s.strip
452
+ s.empty? ? nil : s
453
+ end
454
+
455
+ def tail(text) = text.to_s.strip.lines.last(3).join.strip
456
+
457
+ def failure(msg, t0)
458
+ TurnOutcome.new(result: TurnResult.new(output_text: "", tool_calls: [], ui: [], error: msg.to_s),
459
+ ttfb: nil, total: (mono - t0) * 1000.0, usage: nil)
460
+ end
461
+ end
462
+
463
+ # WHAT THE STORE HOLDS, for the `store_state:` graders. The bench dumps the
464
+ # store to one JSON file ({ collection => [rows] }) after the turn and points
465
+ # the runner at it; the file is re-read on every call, so the same reader serves
466
+ # a whole run as the dump is rewritten between tasks.
467
+ #
468
+ # Deliberately a file and not an MCP client: the harnesses under test reach the
469
+ # store over MCP and nothing else, but the GRADER is not a harness — reading the
470
+ # dumped truth is stricter than asking the same surface the agent could have
471
+ # confused, and it costs no per-store tool mapping.
472
+ class JsonStoreReader
473
+ def initialize(path)
474
+ @path = path
475
+ end
476
+
477
+ def read(collection)
478
+ snapshot = JSON.parse(File.read(@path))
479
+ raise Insika::Error, "#{@path}: expected a mapping of collection -> rows" unless snapshot.is_a?(Hash)
480
+
481
+ Array(snapshot[collection.to_s])
482
+ rescue Errno::ENOENT
483
+ raise Insika::Error, "#{@path}: no store snapshot on disk"
484
+ rescue JSON::ParserError => e
485
+ raise Insika::Error, "#{@path}: #{e.message}"
486
+ end
487
+ end
488
+
489
+ # The store, asked for its own state by running a command — `docker compose exec
490
+ # store dump-state`, or whatever a given store answers with. Preferred over the
491
+ # file reader for a benched harness: the dump then happens AFTER the turn, outside
492
+ # the window the runner is timing, so the store's own latency never lands in the
493
+ # harness's column.
494
+ #
495
+ # The dump is read ONCE per case and cached, because a case asks for several
496
+ # collections and dumping twice would report two different stores. `forget` is how
497
+ # the Runner opens a new case; a reader nobody resets would grade every case
498
+ # against the first one.
499
+ class CommandStoreReader
500
+ def initialize(command:, timeout: 60)
501
+ raise ArgumentError, "command must be an argv array" unless command.is_a?(Array) && !command.empty?
502
+
503
+ @command = command.map(&:to_s)
504
+ @timeout = timeout
505
+ @snapshot = nil
506
+ end
507
+
508
+ def forget = @snapshot = nil
509
+
510
+ def read(collection)
511
+ @snapshot ||= dump
512
+ Array(@snapshot[collection.to_s])
513
+ end
514
+
219
515
  private
220
516
 
221
- # The same shape an HTTP replay produces: [{ "name" =>, "status" => nil }]
222
- # (the stream carries no per-tool status — that lives in the trace store).
223
- def drain_tools(sub)
224
- return [] if sub.nil?
517
+ def dump
518
+ out, err, status = Open3.capture3(*@command)
519
+ raise Insika::Error, "store dump exited #{status.exitstatus}: #{err.to_s.strip.lines.last}" unless status.success?
520
+
521
+ parsed = JSON.parse(out)
522
+ raise Insika::Error, "store dump is not a mapping of collection -> rows" unless parsed.is_a?(Hash)
225
523
 
226
- sub.drain_nonblocking.map { |ev| { "name" => ev.data[:name].to_s, "status" => nil } }
524
+ parsed
525
+ rescue JSON::ParserError => e
526
+ raise Insika::Error, "store dump is not JSON: #{e.message}"
527
+ rescue SystemCallError => e
528
+ raise Insika::Error, "store dump could not run: #{e.message}"
227
529
  end
228
530
  end
229
531
 
@@ -9,6 +9,7 @@ module Insika
9
9
  # has its own queue and `emit` only enqueues. No mutex: one reactor, cooperative
10
10
  # fibers — a plain Array is enough.
11
11
  class EventStream
12
+ EMPTY = [].freeze
12
13
  # One subscription = one queue. The consumer blocks on `each` (its own fiber),
13
14
  # never the emitter.
14
15
  class Subscription
@@ -20,6 +21,8 @@ module Insika
20
21
  # local :error event — the turn never waits on transport.
21
22
  MAX_QUEUED = 1000
22
23
 
24
+ attr_reader :session_id
25
+
23
26
  def initialize(task_id: nil, session_id: nil, tenant: nil, types: nil, on_close: nil)
24
27
  @task_id = task_id
25
28
  @session_id = session_id
@@ -106,19 +109,26 @@ module Insika
106
109
  end
107
110
  end
108
111
 
112
+ # Subscriptions by the session they filter on (nil = any session). A session
113
+ # filter never changes after subscribe, so an event only needs the any-session
114
+ # list plus its own session's: emit does not walk every open conversation.
109
115
  def initialize
110
- @subscriptions = []
116
+ @subscriptions = {}
117
+ @running = {} # session_id -> { task_id -> [Event] } of the turns still running
111
118
  end
112
119
 
113
120
  # NEVER raises: an observer's exception is isolated — a
114
121
  # broken observer does not bring down the turn. Synchronous and cheap.
115
122
  #
116
- # Iterates over a SNAPSHOT (`dup`): a subscription's cap may close it
123
+ # Iterates over a SNAPSHOT (a new array): a subscription's cap may close it
117
124
  # DURING the push (overflow -> close -> on_close removes from the array);
118
125
  # mutating the array in the middle of a plain Array#each would skip the next
119
126
  # subscriber.
120
127
  def emit(event)
121
- @subscriptions.dup.each do |sub|
128
+ session_id = event.meta&.[](:session_id)
129
+ candidates = @subscriptions.fetch(nil, EMPTY) + (session_id.nil? ? EMPTY : @subscriptions.fetch(session_id, EMPTY))
130
+ remember(event, session_id)
131
+ candidates.each do |sub|
122
132
  sub.push(event) if sub.matches?(event)
123
133
  rescue StandardError
124
134
  # a broken observer does not bring down the turn; nothing to propagate
@@ -130,13 +140,59 @@ module Insika
130
140
  # `#each` on its own fiber). `tenant:` scopes the stream to one tenant's
131
141
  # events (WS1) — fail-closed, see Subscription#matches?. `types:` (nil =
132
142
  # any) filters by event type so a subscriber's queue only ever holds what
133
- # its consumer answers (WS6).
134
- def subscribe(task_id: nil, session_id: nil, tenant: nil, types: nil)
143
+ # its consumer answers (WS6). `replay: true` first hands over what the
144
+ # session's running turns already emitted: the transcript is only saved when
145
+ # a turn ends, so a viewer who arrives mid-turn would otherwise miss it.
146
+ def subscribe(task_id: nil, session_id: nil, tenant: nil, types: nil, replay: false)
135
147
  sub = Subscription.new(task_id: task_id, session_id: session_id, tenant: tenant,
136
148
  types: types,
137
- on_close: ->(s) { @subscriptions.delete(s) })
138
- @subscriptions << sub
149
+ on_close: ->(s) { unsubscribe(s) })
150
+ running_events(session_id).each { |event| sub.push(event) if sub.matches?(event) } if replay && session_id
151
+ (@subscriptions[session_id] ||= []) << sub
139
152
  sub
140
153
  end
154
+
155
+ private
156
+
157
+ TERMINAL = %i[task_completed task_failed task_cancelled].freeze
158
+ DELTAS = %i[intermediate thinking].freeze
159
+ # Sessions whose running turn is kept. A turn that never reaches a terminal
160
+ # (a crash) would stay forever; past this, the oldest is dropped.
161
+ # ponytail: count cap, not a TTL — add one if stuck turns pile up.
162
+ MAX_RUNNING = 200
163
+
164
+ # Keeps the running turn of each session; consecutive deltas are merged into
165
+ # one event (carrying the newest meta/seq) so a long turn stays a short list.
166
+ def remember(event, session_id)
167
+ task_id = event.meta&.[](:task_id)
168
+ return if session_id.nil? || task_id.nil?
169
+
170
+ if TERMINAL.include?(event.type)
171
+ turns = @running[session_id] or return
172
+ turns.delete(task_id)
173
+ @running.delete(session_id) if turns.empty?
174
+ return
175
+ end
176
+
177
+ events = ((@running[session_id] ||= {})[task_id] ||= [])
178
+ last = events.last
179
+ if last && DELTAS.include?(event.type) && last.type == event.type
180
+ events[-1] = event.with(data: last.data.merge(delta: last.data[:delta].to_s + event.data[:delta].to_s))
181
+ else
182
+ events << event
183
+ end
184
+ @running.delete(@running.keys.first) if @running.size > MAX_RUNNING
185
+ end
186
+
187
+ def running_events(session_id)
188
+ @running.fetch(session_id, {}).values.flatten
189
+ end
190
+
191
+
192
+ def unsubscribe(sub)
193
+ list = @subscriptions[sub.session_id] or return
194
+ list.delete(sub)
195
+ @subscriptions.delete(sub.session_id) if list.empty?
196
+ end
141
197
  end
142
198
  end