insika 0.9.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 (257) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -2
  3. data/README.md +5 -3
  4. data/bin/insika +3 -0
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +90 -7
  7. data/docs/ARCHITECTURE.md +12 -7
  8. data/docs/ARTIFACTS.md +33 -3
  9. data/docs/BENCHMARK.md +50 -7
  10. data/docs/CONTEXT.md +63 -0
  11. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  12. data/docs/DEPLOY.md +57 -11
  13. data/docs/EMBEDDING.md +10 -0
  14. data/docs/EVALS.md +33 -4
  15. data/docs/FACTS.md +8 -6
  16. data/docs/HARVEST.md +8 -3
  17. data/docs/KNOWLEDGE.md +7 -0
  18. data/docs/MEDIA.md +6 -2
  19. data/docs/OBSERVABILITY.md +143 -22
  20. data/docs/PERFORMANCE.md +110 -0
  21. data/docs/PLUGINS.md +4 -4
  22. data/docs/POLICY.md +22 -13
  23. data/docs/RELEASING.md +5 -0
  24. data/docs/ROUTER.md +7 -0
  25. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  26. data/docs/SECURITY.md +7 -0
  27. data/docs/SHARED-CONVERSATIONS.md +40 -0
  28. data/docs/SKILLS.md +5 -3
  29. data/docs/STUDIO.md +122 -0
  30. data/docs/TOOLS.md +111 -9
  31. data/docs/operate.md +3 -0
  32. data/lib/insika/agent_defaults.rb +140 -0
  33. data/lib/insika/agent_file_store.rb +12 -0
  34. data/lib/insika/agent_profile.rb +78 -8
  35. data/lib/insika/artifact_signing.rb +11 -0
  36. data/lib/insika/chat_builder.rb +79 -25
  37. data/lib/insika/checkpoint.rb +6 -1
  38. data/lib/insika/checkpoint_store.rb +59 -7
  39. data/lib/insika/commands/agent_payload.rb +3 -3
  40. data/lib/insika/commands/delete_kit.rb +26 -0
  41. data/lib/insika/commands/gate_harvest.rb +3 -0
  42. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  43. data/lib/insika/commands/run_harvest.rb +17 -1
  44. data/lib/insika/commands/seed_session.rb +11 -1
  45. data/lib/insika/commands/send_message.rb +9 -3
  46. data/lib/insika/commands/update_settings.rb +23 -1
  47. data/lib/insika/commands/write_kit.rb +27 -0
  48. data/lib/insika/compaction.rb +33 -7
  49. data/lib/insika/config_store.rb +87 -7
  50. data/lib/insika/context/priority.rb +3 -0
  51. data/lib/insika/context/provider.rb +2 -2
  52. data/lib/insika/context/providers/knowledge.rb +25 -2
  53. data/lib/insika/context/providers/memory.rb +53 -4
  54. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  55. data/lib/insika/context/providers/prompt.rb +17 -4
  56. data/lib/insika/context/providers/session.rb +39 -3
  57. data/lib/insika/context/reranker.rb +75 -0
  58. data/lib/insika/distill.rb +22 -13
  59. data/lib/insika/doctor.rb +34 -4
  60. data/lib/insika/dsl/runtime.rb +36 -4
  61. data/lib/insika/dsl/server_boot.rb +4 -12
  62. data/lib/insika/dsl.rb +13 -5
  63. data/lib/insika/edge_limiter.rb +23 -15
  64. data/lib/insika/egress_guard.rb +11 -3
  65. data/lib/insika/env_schema.rb +33 -73
  66. data/lib/insika/evals/assertions.rb +171 -16
  67. data/lib/insika/evals/golden.rb +141 -4
  68. data/lib/insika/evals/judge.rb +19 -10
  69. data/lib/insika/evals/moderator_compare.rb +47 -0
  70. data/lib/insika/evals/report.rb +61 -9
  71. data/lib/insika/evals/runner.rb +44 -4
  72. data/lib/insika/evals/simulator.rb +10 -1
  73. data/lib/insika/evals/transport.rb +200 -0
  74. data/lib/insika/event_stream.rb +63 -7
  75. data/lib/insika/evidence.rb +29 -8
  76. data/lib/insika/executor.rb +668 -141
  77. data/lib/insika/harvest.rb +26 -14
  78. data/lib/insika/harvest_engine.rb +1 -0
  79. data/lib/insika/harvest_store.rb +12 -0
  80. data/lib/insika/http_client.rb +87 -4
  81. data/lib/insika/kits.rb +54 -0
  82. data/lib/insika/knowledge.rb +34 -20
  83. data/lib/insika/llm_configurator.rb +46 -4
  84. data/lib/insika/llm_provider_store.rb +1 -3
  85. data/lib/insika/llm_trace_store.rb +54 -0
  86. data/lib/insika/mcp_client.rb +27 -63
  87. data/lib/insika/mcp_live_tool.rb +55 -9
  88. data/lib/insika/mcp_store.rb +32 -1
  89. data/lib/insika/mcp_tool_registry.rb +69 -26
  90. data/lib/insika/media.rb +38 -25
  91. data/lib/insika/memory_store.rb +4 -3
  92. data/lib/insika/memory_trim.rb +60 -0
  93. data/lib/insika/middleware.rb +12 -3
  94. data/lib/insika/model_metrics_store.rb +285 -0
  95. data/lib/insika/model_registry.rb +110 -0
  96. data/lib/insika/model_resolver.rb +1 -1
  97. data/lib/insika/model_selection.rb +14 -21
  98. data/lib/insika/model_visible.rb +17 -6
  99. data/lib/insika/onboarding.rb +1 -0
  100. data/lib/insika/outcome_store.rb +1 -1
  101. data/lib/insika/overlay_tool_registry.rb +25 -2
  102. data/lib/insika/pack_importer.rb +54 -9
  103. data/lib/insika/packaging.rb +14 -5
  104. data/lib/insika/pending_action_store.rb +45 -18
  105. data/lib/insika/plugin/loader.rb +4 -2
  106. data/lib/insika/profile_source.rb +4 -0
  107. data/lib/insika/provider_error_classifier.rb +18 -3
  108. data/lib/insika/queue_policy.rb +7 -2
  109. data/lib/insika/refinement/proposer.rb +42 -17
  110. data/lib/insika/reliability.rb +12 -3
  111. data/lib/insika/router/app.rb +14 -5
  112. data/lib/insika/router/session_key.rb +35 -5
  113. data/lib/insika/safety/config.rb +20 -3
  114. data/lib/insika/safety/corpus.rb +10 -3
  115. data/lib/insika/safety/factory.rb +39 -1
  116. data/lib/insika/safety/input_guardrail.rb +21 -1
  117. data/lib/insika/safety/judge_moderator.rb +70 -0
  118. data/lib/insika/safety/output_validator.rb +29 -5
  119. data/lib/insika/safety/prompt_echo.rb +89 -0
  120. data/lib/insika/schema_guard.rb +53 -76
  121. data/lib/insika/server/app.rb +17 -8
  122. data/lib/insika/server/responses.rb +19 -1
  123. data/lib/insika/session_actor.rb +58 -28
  124. data/lib/insika/session_store.rb +159 -10
  125. data/lib/insika/settings_store.rb +47 -1
  126. data/lib/insika/shared_conversations.rb +305 -0
  127. data/lib/insika/skill_catalog.rb +28 -2
  128. data/lib/insika/skill_store.rb +6 -0
  129. data/lib/insika/soak/runner.rb +1 -1
  130. data/lib/insika/steer_injector.rb +7 -48
  131. data/lib/insika/store.rb +29 -1
  132. data/lib/insika/stores/json_value.rb +37 -0
  133. data/lib/insika/stores/memory.rb +22 -35
  134. data/lib/insika/stores/postgres.rb +295 -0
  135. data/lib/insika/stores/sqlite.rb +257 -43
  136. data/lib/insika/stores/turn_counter.rb +25 -0
  137. data/lib/insika/studio/app.rb +1453 -161
  138. data/lib/insika/studio/assets/dist/application.css +1 -1
  139. data/lib/insika/studio/assets/dist/application.js +25 -25
  140. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  141. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  142. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  143. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  144. data/lib/insika/studio/forms.rb +171 -9
  145. data/lib/insika/studio/nav_icons.rb +3 -0
  146. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  147. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  148. data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
  149. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  150. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  151. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  152. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  153. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  154. data/lib/insika/studio/views/_agents_master.erb +8 -12
  155. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  156. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  157. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  158. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  159. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  160. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  161. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  162. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  163. data/lib/insika/studio/views/_chats_master.erb +66 -0
  164. data/lib/insika/studio/views/_customers_master.erb +37 -0
  165. data/lib/insika/studio/views/_empty.erb +15 -0
  166. data/lib/insika/studio/views/_facts_master.erb +42 -0
  167. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  168. data/lib/insika/studio/views/_identity.erb +16 -0
  169. data/lib/insika/studio/views/_kpi.erb +17 -0
  170. data/lib/insika/studio/views/_message.erb +40 -2
  171. data/lib/insika/studio/views/_model_chart.erb +82 -0
  172. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  173. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
  174. data/lib/insika/studio/views/_tool_users.erb +17 -0
  175. data/lib/insika/studio/views/_why.erb +53 -0
  176. data/lib/insika/studio/views/agent_detail.erb +17 -10
  177. data/lib/insika/studio/views/agents.erb +28 -16
  178. data/lib/insika/studio/views/approvals.erb +16 -11
  179. data/lib/insika/studio/views/artifact.erb +20 -7
  180. data/lib/insika/studio/views/artifacts.erb +19 -14
  181. data/lib/insika/studio/views/chats.erb +22 -29
  182. data/lib/insika/studio/views/customer.erb +88 -43
  183. data/lib/insika/studio/views/customers.erb +16 -26
  184. data/lib/insika/studio/views/evals.erb +49 -31
  185. data/lib/insika/studio/views/fact_detail.erb +114 -0
  186. data/lib/insika/studio/views/facts.erb +15 -116
  187. data/lib/insika/studio/views/followups.erb +63 -52
  188. data/lib/insika/studio/views/funnel.erb +104 -43
  189. data/lib/insika/studio/views/harvest.erb +85 -59
  190. data/lib/insika/studio/views/home.erb +138 -23
  191. data/lib/insika/studio/views/inbox.erb +36 -0
  192. data/lib/insika/studio/views/kits.erb +176 -0
  193. data/lib/insika/studio/views/knowledge.erb +92 -80
  194. data/lib/insika/studio/views/layout.erb +42 -11
  195. data/lib/insika/studio/views/live.erb +45 -0
  196. data/lib/insika/studio/views/mcp.erb +43 -13
  197. data/lib/insika/studio/views/models.erb +187 -0
  198. data/lib/insika/studio/views/parity.erb +38 -32
  199. data/lib/insika/studio/views/playground.erb +13 -9
  200. data/lib/insika/studio/views/refinement.erb +15 -224
  201. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  202. data/lib/insika/studio/views/session.erb +152 -117
  203. data/lib/insika/studio/views/settings.erb +86 -22
  204. data/lib/insika/studio/views/skills.erb +53 -19
  205. data/lib/insika/studio/views/system_files.erb +7 -7
  206. data/lib/insika/studio/views/task.erb +45 -15
  207. data/lib/insika/studio/views/tasks.erb +35 -18
  208. data/lib/insika/studio/views/tools.erb +62 -73
  209. data/lib/insika/task_actor.rb +4 -1
  210. data/lib/insika/task_store.rb +67 -1
  211. data/lib/insika/telemetry/pricing.rb +16 -14
  212. data/lib/insika/telemetry/recorder.rb +103 -9
  213. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  214. data/lib/insika/telemetry.rb +12 -2
  215. data/lib/insika/templates/daily-digest/README.md +13 -0
  216. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  217. data/lib/insika/testing/store_contract.rb +65 -0
  218. data/lib/insika/text_tool_call.rb +66 -0
  219. data/lib/insika/tick.rb +7 -1
  220. data/lib/insika/token_store.rb +5 -0
  221. data/lib/insika/tool_batch.rb +1 -1
  222. data/lib/insika/tool_catalog.rb +12 -2
  223. data/lib/insika/tool_definition.rb +10 -4
  224. data/lib/insika/tool_envelope.rb +165 -28
  225. data/lib/insika/tool_manifest.rb +1 -0
  226. data/lib/insika/tool_store.rb +3 -0
  227. data/lib/insika/tool_trace_store.rb +15 -8
  228. data/lib/insika/tool_usage_report.rb +2 -2
  229. data/lib/insika/tools/a2a_remote.rb +1 -1
  230. data/lib/insika/tools/agent_enum.rb +1 -1
  231. data/lib/insika/tools/confirm_pending.rb +112 -0
  232. data/lib/insika/tools/data_defined_tool.rb +23 -15
  233. data/lib/insika/tools/generate_image.rb +2 -2
  234. data/lib/insika/tools/load_knowledge.rb +7 -3
  235. data/lib/insika/tools/load_skill.rb +3 -3
  236. data/lib/insika/tools/present.rb +1 -8
  237. data/lib/insika/tools/remember.rb +7 -2
  238. data/lib/insika/tools/run_persona_eval.rb +3 -5
  239. data/lib/insika/tools/save_artifact.rb +16 -6
  240. data/lib/insika/tools/schedule_followup.rb +3 -3
  241. data/lib/insika/tools/stuck_signal.rb +3 -3
  242. data/lib/insika/tools/subagent.rb +5 -5
  243. data/lib/insika/tools/subagents.rb +3 -3
  244. data/lib/insika/tools/tool_search.rb +7 -5
  245. data/lib/insika/tools/tts.rb +2 -2
  246. data/lib/insika/tools/update_briefing.rb +4 -4
  247. data/lib/insika/turn_output.rb +2 -2
  248. data/lib/insika/turn_state.rb +18 -0
  249. data/lib/insika/turn_timing.rb +51 -14
  250. data/lib/insika/version.rb +1 -1
  251. data/lib/insika/wiring/graph.rb +81 -16
  252. data/lib/insika/workflow.rb +25 -64
  253. data/lib/insika.rb +19 -3
  254. metadata +69 -13
  255. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  256. data/lib/insika/mcp_http_client.rb +0 -67
  257. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -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,9 +72,16 @@ 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}"
69
86
  if golden.seeded?
70
87
  # The snapshot goes in BEFORE turn 1 — on the same conversation id the turns
@@ -101,7 +118,10 @@ module Insika
101
118
  last = turns.last
102
119
  # Tool/content assertions read the last turn (unchanged); the policy checks
103
120
  # read every turn — "one question per reply" is a rule about each of them.
104
- 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?)
105
125
  # Subjective layer: only when a judge is configured, the case has a rubric, and
106
126
  # the turn ran cleanly (nothing to judge on an errored turn).
107
127
  result.judge = @judge.score(golden: golden, result: last) if @judge && result.rubric && result.error.nil?
@@ -110,11 +130,31 @@ module Insika
110
130
  # comparison for a reason that has nothing to do with the agent.
111
131
  result.pairwise = @pairwise.compare(golden: golden, turns: turns) if @pairwise && result.error.nil?
112
132
  RunCase.new(result: result, timings: timings, tokens: sum_tokens(spent),
113
- cached: sum_tokens(cached))
133
+ cached: sum_tokens(cached), turns: turns, store: store)
114
134
  end
115
135
 
116
136
  private
117
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
+
118
158
  # nil when NO turn reported usage; otherwise the sum of the ones that did. A
119
159
  # partially-metered case is reported as what was actually measured, low rather
120
160
  # than absent — a budget under-counting is a smaller lie than a budget that
@@ -166,12 +166,21 @@ module Insika
166
166
  # [tool names] the agent can reach that are marked side-effect, sorted.
167
167
  def side_effect_tools(profile, registry)
168
168
  allowed = if profile.tools_allow.nil? then Array(registry.names)
169
- else Array(profile.tools_allow).map(&:to_s)
169
+ else Array(profile.tools_allow).map(&:to_s) | group_tools(profile, registry)
170
170
  end
171
171
  denied = Array(profile.tools_deny).map(&:to_s)
172
172
  (allowed - denied).select { |name| registry.side_effect?(name) }.sort
173
173
  end
174
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
+
175
184
  # -> bool: can a simulated run touch this agent without a staging
176
185
  # declaration (no reachable side-effect tool)?
177
186
  def safe?(profile, registry)
@@ -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
@@ -329,6 +330,205 @@ module Insika
329
330
  end
330
331
  end
331
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
+
515
+ private
516
+
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)
523
+
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}"
529
+ end
530
+ end
531
+
332
532
  # Thin A2A transport: drives a REMOTE A2A agent (an agent that only speaks
333
533
  # A2A) through the same `turn` seam the Simulator uses. The outbound A2A
334
534
  # client already does the send+poll; this is the wrapper that makes it a
@@ -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
@@ -26,7 +26,11 @@ module Insika
26
26
  #
27
27
  # `kind` is the PILOT PACK's value, never a gem constant (the
28
28
  # engine owns nothing about product shape).
29
- Spec = Data.define(:kind, :items_path, :attachments_path) do
29
+ # `id`/`line` name the FIELDS inside an item, because a store's own payload
30
+ # calls them what it calls them (`product_id`, `name`) and a server nobody
31
+ # controls — an MCP server in particular — will not rename them for us.
32
+ # Defaults keep every existing spec byte-identical.
33
+ Spec = Data.define(:kind, :items_path, :attachments_path, :id_field, :line_field) do
30
34
  PATH_RE = /\A[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\z/
31
35
 
32
36
  # String | Hash | Spec | nil -> Spec | nil. Raises ValidationError on a blank kind
@@ -43,13 +47,28 @@ module Insika
43
47
 
44
48
  items = presence_path(h["items"], "items")
45
49
  attachments = presence_path(h["attachments"], "attachments")
46
- new(kind: kind, items_path: items, attachments_path: attachments)
50
+ new(kind: kind, items_path: items, attachments_path: attachments,
51
+ id_field: presence_field(h["id"], "id"), line_field: presence_field(h["line"], "line"))
47
52
  end
48
53
 
54
+ # A default field is left OUT: a spec that names nothing new serializes
55
+ # exactly as it did before this existed.
49
56
  def to_h
50
- { "kind" => kind, "items" => items_path, "attachments" => attachments_path }.compact
57
+ { "kind" => kind, "items" => items_path, "attachments" => attachments_path,
58
+ "id" => (id_field unless id_field == "id"),
59
+ "line" => (line_field unless line_field == "line") }.compact
51
60
  end
52
61
 
62
+ # A field name, not a path: the item is one flat object and a dotted dig
63
+ # inside it would be a different feature.
64
+ def self.presence_field(value, default)
65
+ s = Coercion.presence(value) || default
66
+ raise Insika::ValidationError, "evidence.#{default}: not a field name" unless /\A[a-zA-Z0-9_]+\z/.match?(s)
67
+
68
+ s
69
+ end
70
+ private_class_method :presence_field
71
+
53
72
  def self.presence_path(value, default)
54
73
  s = Coercion.presence(value)
55
74
  s = default if s.nil?
@@ -119,14 +138,15 @@ module Insika
119
138
  # model must never see a null where the contract says items.
120
139
  def build(spec, raw)
121
140
  items = SchemaGuard.dig(raw, spec.items_path) || []
141
+ id_of = ->(item) { item[spec.id_field] || item[spec.id_field.to_sym] }
122
142
  lean_items = items.first(MAX_ITEMS).map do |item|
123
- { "id" => (item["id"] || item[:id]).to_s,
143
+ { "id" => id_of.call(item).to_s,
124
144
  # the line is what the model reads: truncated here; sanitized by the
125
145
  # envelope's fence when the agent has `fencing` on (bytes as-is when off).
126
- "line" => Coercion.utf8((item["line"] || item[:line]).to_s)[0, LINE_MAX] }
146
+ "line" => Coercion.utf8((item[spec.line_field] || item[spec.line_field.to_sym]).to_s)[0, LINE_MAX] }
127
147
  end
128
148
  lean = { "items" => lean_items }
129
- cards = stamp_ids(items, SchemaGuard.dig(raw, spec.attachments_path))
149
+ cards = stamp_ids(items, SchemaGuard.dig(raw, spec.attachments_path), id_of)
130
150
  [lean, Insika::Evidence.valid_attachments(cards)]
131
151
  end
132
152
 
@@ -135,12 +155,12 @@ module Insika
135
155
  # its position. That id is what a presentation tool later joins on. Stamped
136
156
  # on the RAW list, before malformed cards are dropped: pairing after the
137
157
  # drop would shift every later card onto the wrong product.
138
- def stamp_ids(items, cards)
158
+ def stamp_ids(items, cards, id_of = ->(item) { item["id"] || item[:id] })
139
159
  Array(cards).each_with_index.map do |card, i|
140
160
  next card unless card.is_a?(Hash) && (card["id"] || card[:id]).nil?
141
161
 
142
162
  item = items[i]
143
- id = item.is_a?(Hash) ? (item["id"] || item[:id]).to_s : ""
163
+ id = item.is_a?(Hash) ? id_of.call(item).to_s : ""
144
164
  id.empty? ? card : card.merge("id" => id)
145
165
  end
146
166
  end
@@ -217,6 +237,7 @@ module Insika
217
237
  # "nothing to append", never an explosion).
218
238
  def flush!
219
239
  return self unless @store && @session_id
240
+ return self if @ids.empty? && @cards.empty? && @ungrounded.zero? # nothing new: no session rewrite
220
241
 
221
242
  @store.append_evidence(@session_id, ids: @ids, ungrounded: @ungrounded, cards: @cards)
222
243
  @ids = []