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
@@ -37,8 +37,13 @@ module Insika
37
37
  # rubric alone decides, and inventing a default here would be inventing an
38
38
  # opinion for someone else's store.
39
39
  POLICY_INSTRUCTIONS = {
40
- "ask_once" => "This store allows AT MOST ONE question per reply. Two questions in " \
41
- "one message is a failure even if the content is otherwise good.",
40
+ # %<max>s is the store's number, not ours. Stated to the judge in the same
41
+ # words and with the same threshold the deterministic layer uses — a judge
42
+ # told "at most one" while the counter allows two grades a different rule.
43
+ "ask_once" => "This store allows AT MOST %<max>s question(s) per reply. One more " \
44
+ "than that in a single message is a failure even if the content is " \
45
+ "otherwise good. A greeting formula that asks nothing the assistant " \
46
+ "needs (\"tudo bem?\", \"how are you?\") is courtesy, not a question.",
42
47
  "investigate_first" => "This store wants the objective established BEFORE acting: on a " \
43
48
  "vague request the assistant should ask (once or twice, not a " \
44
49
  "form), not search immediately.",
@@ -66,7 +71,8 @@ module Insika
66
71
  rubric = golden.rubric.to_s.strip
67
72
  return nil if rubric.empty?
68
73
 
69
- prompt = build_prompt(rubric, golden.user_turns, result.output_text.to_s, golden.policy)
74
+ prompt = build_prompt(rubric, golden.user_turns, result.output_text.to_s, golden.policy,
75
+ golden.respond_to?(:policy_options) ? golden.policy_options : {})
70
76
  score_prompt(prompt, golden.min_score || DEFAULT_MIN_SCORE)
71
77
  end
72
78
 
@@ -75,11 +81,12 @@ module Insika
75
81
  # [{ role: "user"|"assistant", text: }] in order. -> Verdict, or nil without
76
82
  # a rubric. Same panel, same rules as `score`: the rubric that scores a
77
83
  # scripted replay scores a generated one.
78
- def score_conversation(rubric:, transcript:, policy: nil, min_score: DEFAULT_MIN_SCORE)
84
+ def score_conversation(rubric:, transcript:, policy: nil, policy_options: {},
85
+ min_score: DEFAULT_MIN_SCORE)
79
86
  rubric = rubric.to_s.strip
80
87
  return nil if rubric.empty?
81
88
 
82
- prompt = conversation_prompt(rubric, transcript, policy)
89
+ prompt = conversation_prompt(rubric, transcript, policy, policy_options)
83
90
  score_prompt(prompt, min_score)
84
91
  end
85
92
 
@@ -102,7 +109,7 @@ module Insika
102
109
  # user turn and every assistant reply interleaved. A rubric about the whole
103
110
  # exchange ("does it discover the objective before recommending?") is
104
111
  # unanswerable from the last reply alone.
105
- def conversation_prompt(rubric, transcript, policy)
112
+ def conversation_prompt(rubric, transcript, policy, policy_options = {})
106
113
  lines = Array(transcript).map do |m|
107
114
  role = m[:role].to_s == "user" ? "customer" : "assistant"
108
115
  "#{role}: #{m[:text].to_s.strip}"
@@ -114,7 +121,7 @@ module Insika
114
121
 
115
122
  RUBRIC:
116
123
  #{rubric}
117
- #{policy_clause(policy)}
124
+ #{policy_clause(policy, policy_options)}
118
125
  CONVERSATION (in order):
119
126
  #{lines}
120
127
 
@@ -144,14 +151,14 @@ module Insika
144
151
  # much this store wants the agent to ask before acting is a per-store decision,
145
152
  # and a judge that is not TOLD it will guess — half the time wrongly. The
146
153
  # deterministic half is `Assertions.policy_checks`; this is the other half.
147
- def build_prompt(rubric, user_turns, reply, policy = nil)
154
+ def build_prompt(rubric, user_turns, reply, policy = nil, policy_options = {})
148
155
  <<~PROMPT
149
156
  You are a strict QA judge for a customer-service AI assistant. Judge the
150
157
  ASSISTANT REPLY against the RUBRIC — nothing else.
151
158
 
152
159
  RUBRIC:
153
160
  #{rubric}
154
- #{policy_clause(policy)}
161
+ #{policy_clause(policy, policy_options)}
155
162
  CONVERSATION (user turns, in order):
156
163
  #{user_turns.map { |t| "- #{t}" }.join("\n")}
157
164
 
@@ -164,10 +171,12 @@ module Insika
164
171
  PROMPT
165
172
  end
166
173
 
167
- def policy_clause(policy)
174
+ def policy_clause(policy, policy_options = {})
168
175
  instruction = POLICY_INSTRUCTIONS[policy.to_s]
169
176
  return "" unless instruction
170
177
 
178
+ instruction = format(instruction, max: (policy_options || {}).fetch("max", 1)) if
179
+ instruction.include?("%<max>s")
171
180
  "\nSTORE POLICY (weigh this as part of the rubric):\n#{instruction}\n"
172
181
  end
173
182
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Evals
5
+ # Runs each moderator over the same labeled messages and counts how they
6
+ # differ from the label. Pure: the moderators are injected.
7
+ class ModeratorCompare
8
+ BLOCKS = %w[refuse escalate].freeze
9
+
10
+ def initialize(moderators:)
11
+ @moderators = moderators
12
+ end
13
+
14
+ def run(cases)
15
+ @moderators.to_h { |name, mod| [name, score(mod, cases)] }
16
+ end
17
+
18
+ private
19
+
20
+ def score(mod, cases)
21
+ r = { "correct" => 0, "total" => cases.size, "false_blocks" => 0, "missed_blocks" => 0,
22
+ "unavailable" => 0, "misses" => [], "verdicts" => {} }
23
+ times = cases.map do |c|
24
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ v = mod.classify(c["message"])
26
+ tally(r, c, v)
27
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
28
+ end.sort
29
+ r.merge("p50_ms" => pct(times, 0.5), "p95_ms" => pct(times, 0.95))
30
+ end
31
+
32
+ def tally(r, c, v)
33
+ r["verdicts"][c["id"]] = { "action" => v.action.to_s, "category" => v.category.to_s, "reason" => v.reason.to_s }
34
+ r["unavailable"] += 1 if v.unavailable?
35
+ want_block = BLOCKS.include?(c["expected"])
36
+ ok = want_block ? v.block? : !v.block? && !v.unavailable?
37
+ return r["correct"] += 1 if ok
38
+
39
+ r["misses"] << c["id"]
40
+ r["false_blocks"] += 1 if !want_block && v.block?
41
+ r["missed_blocks"] += 1 if want_block && !v.block?
42
+ end
43
+
44
+ def pct(sorted, q) = sorted.empty? ? 0 : sorted[((sorted.size - 1) * q).round].round(1)
45
+ end
46
+ end
47
+ end
@@ -12,7 +12,15 @@ module Insika
12
12
  module_function
13
13
 
14
14
  # -> Hash ready for JSON. `at` is an ISO-8601 string stamped by the caller.
15
- def to_h(results, at:)
15
+ #
16
+ # `perf` is what each case COST, keyed by case id: `{"ms" =>, "turns" =>,
17
+ # "tokens" =>, "cached" =>}`. Optional, because the answer to "did it pass" must
18
+ # not depend on anyone having measured the clock — but a cross-harness table
19
+ # without it is a table about correctness only, and a merchant asks how long the
20
+ # customer waited before asking anything else. Wall clock is measured by the
21
+ # RUNNER around the turn, never self-reported: it is the one column every
22
+ # entrant can be held to.
23
+ def to_h(results, at:, perf: nil)
16
24
  passed = results.count(&:pass?)
17
25
  skipped = results.count(&:skipped?)
18
26
  {
@@ -27,17 +35,22 @@ module Insika
27
35
  # Absent (not an empty tally) when nothing was compared — a run with no
28
36
  # pairwise is the normal one, and a zeroed block reads like every case tied.
29
37
  "pairwise" => pairwise_summary(results),
38
+ "perf" => perf_summary(perf),
30
39
  "cases" => results.map do |r|
31
40
  {
32
41
  "id" => r.id, "agent" => r.agent, "pass" => r.pass?,
33
42
  "skipped" => r.skipped,
34
43
  "judge_pending" => r.judge_pending?, "error" => r.error,
35
- "checks" => r.checks.map { |c| { "name" => c.name, "pass" => c.pass, "detail" => c.detail } },
44
+ "checks" => r.checks.map do |c|
45
+ { "name" => c.name, "pass" => c.pass, "detail" => c.detail,
46
+ "skipped" => (true if c.skipped?) }.compact
47
+ end,
36
48
  "judge" => (r.judge && { "score" => r.judge.score, "pass" => r.judge.pass, "reason" => r.judge.reason }),
37
49
  "pairwise" => (r.pairwise && { "outcome" => r.pairwise.outcome, "vs" => r.pairwise.vs,
38
50
  "reason" => r.pairwise.reason, "judges" => r.pairwise.judges,
39
- "order_dependent" => r.pairwise.order_dependent })
40
- }
51
+ "order_dependent" => r.pairwise.order_dependent }),
52
+ "perf" => perf&.[](r.id)
53
+ }.compact
41
54
  end
42
55
  }.compact
43
56
  end
@@ -57,8 +70,36 @@ module Insika
57
70
  "outcomes" => counts }
58
71
  end
59
72
 
60
- def to_json(results, at:)
61
- JSON.pretty_generate(to_h(results, at: at))
73
+ # nil when nothing was timed — an absent block, never a zeroed one: "nobody
74
+ # measured" and "it took no time" are different facts, and a table that confuses
75
+ # them publishes a harness as instant.
76
+ def perf_summary(perf)
77
+ measured = Array(perf&.values).select { |v| v["ms"] }
78
+ return nil if measured.empty?
79
+
80
+ case_ms = measured.map { |v| v["ms"].to_f }.sort
81
+ turn_ms = measured.flat_map { |v| Array(v["turn_ms"]) }.compact.sort
82
+ tokens = measured.filter_map { |v| v["tokens"] }
83
+ { "cases_measured" => measured.size,
84
+ "case_ms_p50" => percentile(case_ms, 50), "case_ms_p95" => percentile(case_ms, 95),
85
+ "turn_ms_p50" => percentile(turn_ms, 50), "turn_ms_p95" => percentile(turn_ms, 95),
86
+ # Absent rather than zero when no provider reported usage — most rival
87
+ # harnesses will not, and a zero there reads as free.
88
+ "tokens_total" => (tokens.sum unless tokens.empty?),
89
+ "tokens_measured" => tokens.size }.compact
90
+ end
91
+
92
+ def percentile(sorted, p)
93
+ return nil if sorted.empty?
94
+
95
+ r = (p / 100.0) * (sorted.length - 1)
96
+ lo = sorted[r.floor]
97
+ hi = sorted[r.ceil]
98
+ (lo + ((hi - lo) * (r - r.floor))).round
99
+ end
100
+
101
+ def to_json(results, at:, perf: nil)
102
+ JSON.pretty_generate(to_h(results, at: at, perf: perf))
62
103
  end
63
104
 
64
105
  PAIRWISE_MARK = { "better" => "🟢", "comparable" => "🟡", "worse" => "🔴",
@@ -85,8 +126,8 @@ module Insika
85
126
  end
86
127
 
87
128
  # Human summary. One line per case; failing checks nested underneath.
88
- def to_markdown(results, at:)
89
- h = to_h(results, at: at)
129
+ def to_markdown(results, at:, perf: nil)
130
+ h = to_h(results, at: at, perf: perf)
90
131
  lines = ["# Eval report — #{at}", "",
91
132
  "**#{h['passed']}/#{h['total'] - h['skipped']} passed** · #{h['failed']} failed" \
92
133
  "#{" · #{h['skipped']} skipped" if h['skipped'].positive?}" \
@@ -99,14 +140,25 @@ module Insika
99
140
  next
100
141
  end
101
142
 
102
- lines << "- #{r.pass? ? '✅' : '❌'} `#{r.id}` (#{r.agent})#{' ⏳ judge pending' if r.judge_pending?}"
143
+ took = perf&.dig(r.id, "ms")
144
+ lines << "- #{r.pass? ? '✅' : '❌'} `#{r.id}` (#{r.agent})" \
145
+ "#{" · #{(took / 1000.0).round(1)}s" if took}" \
146
+ "#{' ⏳ judge pending' if r.judge_pending?}"
103
147
  r.failures.each { |c| lines << " - ❌ #{c.name}: #{c.detail}" }
148
+ # A grader the transport could not feed. Printed even on a passing case:
149
+ # "passed" over checks nobody could run is the claim this line prevents.
150
+ r.skipped_checks.each { |c| lines << " - ⏭️ #{c.name}: #{c.detail}" }
104
151
  if r.judge
105
152
  v = r.judge
106
153
  lines << " - #{v.pass ? '✅' : '❌'} judge: #{v.score} — #{v.reason}"
107
154
  end
108
155
  lines << " - #{pairwise_line(r.pairwise)}" if r.pairwise
109
156
  end
157
+ if (summary = h["perf"])
158
+ lines << "" << "**time** (#{summary['cases_measured']} case(s)): " \
159
+ "case p50/p95 #{summary['case_ms_p50']} / #{summary['case_ms_p95']} ms · " \
160
+ "turn p50/p95 #{summary['turn_ms_p50']} / #{summary['turn_ms_p95']} ms"
161
+ end
110
162
  lines.concat(pairwise_block(h["pairwise"])) if h["pairwise"]
111
163
  "#{lines.join("\n")}\n"
112
164
  end
@@ -19,7 +19,11 @@ module Insika
19
19
  # cache, carried separately because it is the number that explains a total.
20
20
  # Only the refinement gate reads them (records a run's cost); the
21
21
  # report and the exit code are untouched.
22
- RunCase = Struct.new(:result, :timings, :tokens, :cached, keyword_init: true)
22
+ # `turns` and `store` are what the case actually SAW: every turn's reply and
23
+ # tool calls, and the store as it stood afterwards. The verdict alone says a
24
+ # cell failed; only these say what the harness answered, and a cross-harness
25
+ # table is unreadable without being able to go and look.
26
+ RunCase = Struct.new(:result, :timings, :tokens, :cached, :turns, :store, keyword_init: true)
23
27
 
24
28
  # judge: an Evals::Judge (optional). When set, a case with a rubric whose turn
25
29
  # ran cleanly gets a subjective verdict attached on top of the deterministic pass.
@@ -35,12 +39,18 @@ module Insika
35
39
  # pairwise: an Evals::Pairwise (optional). Only cases carrying a
36
40
  # `reference:` are compared, and the verdict never touches pass/fail — it is the
37
41
  # answer to "can we replace it", reported beside the suite's own verdict.
38
- def initialize(transport:, judge: nil, conv_map: {}, capabilities: nil, pairwise: nil)
42
+ #
43
+ # store_reader: how a case's `store_state:` is graded — anything answering
44
+ # `#read(collection)` with the collection's rows. Only the cases that declare a
45
+ # `store_state:` ask for it; a case that declares one with no reader configured
46
+ # is SKIPPED with the reason, never run and passed on its reply alone.
47
+ def initialize(transport:, judge: nil, conv_map: {}, capabilities: nil, pairwise: nil, store_reader: nil)
39
48
  @transport = transport
40
49
  @judge = judge
41
50
  @conv_map = conv_map || {}
42
51
  @capabilities = capabilities
43
52
  @pairwise = pairwise
53
+ @store_reader = store_reader
44
54
  end
45
55
 
46
56
  # [Golden] -> [RunCase]. Each RunCase carries the CaseResult (for the report) +
@@ -62,10 +72,37 @@ module Insika
62
72
  skip = skip_reason(golden)
63
73
  return RunCase.new(result: Assertions.skip(golden, skip), timings: []) if skip
64
74
 
75
+ if golden.store_state? && @store_reader.nil?
76
+ return RunCase.new(result: Assertions.skip(golden, "no store reader configured for `store_state`"),
77
+ timings: [])
78
+ end
79
+
65
80
  # A backend that resolves state from a pre-existing conversation (e.g. a
66
81
  # consumer needing a real Chat UUID as X-Chat-Id) supplies it via conv_map; otherwise the
67
82
  # synthetic "eval-<id>" keeps the adapter's own multi-turn continuation.
83
+ # A reader that caches its snapshot is opening a new case now.
84
+ @store_reader.forget if @store_reader.respond_to?(:forget)
68
85
  conv = @conv_map[golden.id] || "eval-#{golden.id}"
86
+ if golden.seeded?
87
+ # The snapshot goes in BEFORE turn 1 — on the same conversation id the turns
88
+ # continue. A transport that cannot seed (A2A: a remote agent has no seed
89
+ # route) SKIPS the case with the reason, never runs it against an empty
90
+ # state and passes. A deployment that refuses seeding (the setting is off)
91
+ # is the same outcome: skipped and reported, which is what the `requires`
92
+ # discipline already promises for anything the deployment lacks.
93
+ unless @transport.respond_to?(:seed)
94
+ return RunCase.new(result: Assertions.skip(golden, "transport cannot seed state"), timings: [])
95
+ end
96
+
97
+ begin
98
+ @transport.seed(conv, golden.state)
99
+ rescue SeedRefused => e
100
+ return RunCase.new(result: Assertions.skip(golden, "deployment refuses seeding — #{e.message}"), timings: [])
101
+ rescue Insika::Error => e
102
+ failed = TurnResult.new(output_text: "", tool_calls: [], error: "seed failed: #{e.message}")
103
+ return RunCase.new(result: Assertions.evaluate(golden, failed), timings: [])
104
+ end
105
+ end
69
106
  turns = []
70
107
  timings = []
71
108
  spent = []
@@ -81,7 +118,10 @@ module Insika
81
118
  last = turns.last
82
119
  # Tool/content assertions read the last turn (unchanged); the policy checks
83
120
  # read every turn — "one question per reply" is a rule about each of them.
84
- result = Assertions.evaluate(golden, last, turns: turns)
121
+ # The store is read AFTER the last turn: what the conversation left behind.
122
+ store, store_error = read_store(golden) if golden.store_state? && last.error.nil?
123
+ result = Assertions.evaluate(golden, last, turns: turns, store: store, store_error: store_error,
124
+ tools_reported: tools_reported?)
85
125
  # Subjective layer: only when a judge is configured, the case has a rubric, and
86
126
  # the turn ran cleanly (nothing to judge on an errored turn).
87
127
  result.judge = @judge.score(golden: golden, result: last) if @judge && result.rubric && result.error.nil?
@@ -90,11 +130,31 @@ module Insika
90
130
  # comparison for a reason that has nothing to do with the agent.
91
131
  result.pairwise = @pairwise.compare(golden: golden, turns: turns) if @pairwise && result.error.nil?
92
132
  RunCase.new(result: result, timings: timings, tokens: sum_tokens(spent),
93
- cached: sum_tokens(cached))
133
+ cached: sum_tokens(cached), turns: turns, store: store)
94
134
  end
95
135
 
96
136
  private
97
137
 
138
+ # Does the transport see tool calls at all? Only a benched harness answers no
139
+ # (an adapter that can report the text and nothing else). Anything that does
140
+ # not answer the question is assumed to report them, which is what every
141
+ # transport in the engine does.
142
+ def tools_reported?
143
+ return true unless @transport.respond_to?(:reports_tool_calls?)
144
+
145
+ @transport.reports_tool_calls?
146
+ end
147
+
148
+ # -> [{ collection => [rows] }, nil] or [nil, why]. A reader that raises fails
149
+ # the store graders WITH the reason rather than killing the run: one unreadable
150
+ # cell of a bench table must not throw away the other thirty-nine, and a store
151
+ # nobody could read is never a store that matched.
152
+ def read_store(golden)
153
+ [golden.store_collections.to_h { |c| [c, Array(@store_reader.read(c))] }, nil]
154
+ rescue StandardError => e
155
+ [nil, "#{e.class}: #{e.message}"]
156
+ end
157
+
98
158
  # nil when NO turn reported usage; otherwise the sum of the ones that did. A
99
159
  # partially-metered case is reported as what was actually measured, low rather
100
160
  # than absent — a budget under-counting is a smaller lie than a budget that
@@ -95,11 +95,20 @@ module Insika
95
95
  @safety = safety
96
96
  end
97
97
 
98
- # Runs one simulated conversation. -> SimulatedRun.
99
- def run(persona:, agent:, conv:)
98
+ # Runs one simulated conversation. -> SimulatedRun. `state` is the case's
99
+ # snapshot (Golden#state), loaded into the conversation before the persona's
100
+ # opening line — a simulated customer can start from "already saw three
101
+ # products" too. Empty/nil = the conversation starts empty, as before.
102
+ def run(persona:, agent:, conv:, state: nil)
100
103
  reason = @safety.refusal
101
104
  raise UnsafeTarget, reason if reason
102
105
 
106
+ if state && !state.empty?
107
+ raise Insika::Error, "transport cannot seed state" unless @transport.respond_to?(:seed)
108
+
109
+ @transport.seed(conv, state)
110
+ end
111
+
103
112
  transcript = []
104
113
  message = persona.opens_with
105
114
  stop = :max_turns
@@ -157,12 +166,21 @@ module Insika
157
166
  # [tool names] the agent can reach that are marked side-effect, sorted.
158
167
  def side_effect_tools(profile, registry)
159
168
  allowed = if profile.tools_allow.nil? then Array(registry.names)
160
- else Array(profile.tools_allow).map(&:to_s)
169
+ else Array(profile.tools_allow).map(&:to_s) | group_tools(profile, registry)
161
170
  end
162
171
  denied = Array(profile.tools_deny).map(&:to_s)
163
172
  (allowed - denied).select { |name| registry.side_effect?(name) }.sort
164
173
  end
165
174
 
175
+ # Tools reachable through tools_allow_groups (an MCP server, a kit's groups):
176
+ # the registry entries whose metadata group the agent allows.
177
+ def group_tools(profile, registry)
178
+ groups = Array(profile.tools_allow_groups).map(&:to_s)
179
+ return [] if groups.empty? || !registry.respond_to?(:entries)
180
+
181
+ registry.entries.select { |e| groups.include?(e.metadata[:group].to_s) }.map { |e| e.name.to_s }
182
+ end
183
+
166
184
  # -> bool: can a simulated run touch this agent without a staging
167
185
  # declaration (no reachable side-effect tool)?
168
186
  def safe?(profile, registry)