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
@@ -1,44 +1,59 @@
1
- <%# Tools — miller columns: [data tools + agents] master |
2
- agent tool matrix in a turbo-frame. Master rows for agents frame-navigate
3
- (?a=) with the URL advancing; data-tool rows keep full-page navigation to
4
- their standalone editor. @sel_agent (from ?a=) drives the detail. %>
5
- <%# Roda renders with plain locals — no local_assigns hash (Rails-ism). %>
1
+ <%# Tools — the catalog, as miller columns: every tool in the master (grouped by
2
+ origin, searchable, with how many agents can call it) | the detail in a
3
+ turbo-frame: a data tool's editor, or a read-only card for code and MCP
4
+ tools. Which agent may call what lives on the agent (its Tools tab).
5
+ @detail: nil (landing) | :edit (data-tool editor) | :info (read-only card). %>
6
6
  <% frame_only = defined?(frame_only) && frame_only %>
7
7
  <% unless frame_only %>
8
+ <header class="page-head">
9
+ <h1>Tools<span class="sub">Every tool agents can call — edit HTTP tools, see who uses each one</span></h1>
10
+ </header>
8
11
  <div class="drill" data-controller="list-filter">
9
12
  <aside class="drill-pane drill-master">
10
13
  <div class="drill-pane-head">
11
- <div class="title"><strong>Tools <span class="count"><%= @tools.size %></span></strong></div>
12
- <a class="btn primary btn-sm" href="/studio/tools/def/new">+ data tool</a>
14
+ <div class="title"><strong>Tools <span class="count" data-list-filter-target="count"><%= @tools.size %></span></strong></div>
15
+ <a class="btn primary btn-sm" href="/studio/tools/def/new" data-turbo-frame="tool-detail" data-turbo-action="advance">+ New tool</a>
13
16
  </div>
14
17
  <div class="drill-search">
15
- <input type="search" class="filter-input" placeholder="Filter agents…" aria-label="Filter agents"
18
+ <input type="search" class="filter-input" placeholder="Search tools…" aria-label="Search tools"
16
19
  data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
20
+ <label class="switch" title="show only tools no agent can call">
21
+ <input type="checkbox" id="only-unused">
22
+ <span class="switch-track" aria-hidden="true"></span>
23
+ <span class="switch-label">only unused</span>
24
+ </label>
17
25
  </div>
18
- <nav class="drill-pane-body drill-list" aria-label="tools and agents" data-controller="active-link">
19
- <% unless @data_tool_names.empty? %>
20
- <div class="drill-group-label">Data tools · HTTP, editable</div>
21
- <% @data_tool_names.sort.each do |name| %>
22
- <% dropped = @dropped_tool_names.include?(name) %>
23
- <a class="drill-item drill-item-sm" href="/studio/tools/def/<%= Rack::Utils.escape(name) %>"
24
- <%== %( title="its definition is invalid — no agent can call it until it is fixed") if dropped %>>
25
- <div class="drill-item-name"><span class="txt" title="<%= name %>"><%= name %></span><span class="pill plain">data</span><% if dropped %> <span class="pill locked">dropped</span><% end %></div>
26
- </a>
27
- <% end %>
26
+ <nav class="drill-pane-body drill-list" aria-label="tools" data-controller="active-link">
27
+ <% @tool_groups.each do |label, tools| %>
28
+ <details class="drill-group" data-list-filter-target="group"<%== " open" unless label.start_with?("MCP") %>>
29
+ <summary class="drill-group-label"><%= label %> <span class="count"><%= tools.size %></span></summary>
30
+ <% tools.each do |t| %>
31
+ <% data = @data_tool_names.include?(t.name) %>
32
+ <% users = @tool_reach[t.name].size %>
33
+ <a class="drill-item drill-item-sm<%== ' active' if @selected == t.name %>"
34
+ href="/studio/tools/<%= data ? "def" : "info" %>/<%= Rack::Utils.escape(t.name) %>"<%== " data-unused" if users.zero? %>
35
+ data-turbo-frame="tool-detail" data-turbo-action="advance"
36
+ data-list-filter-target="item" data-filter-text="<%= "#{t.name} #{label}" %>"
37
+ data-active-link-target="item" data-action="click->active-link#select" title="<%= t.description %>">
38
+ <div class="drill-item-name"><span class="txt"><%= t.name %></span><% if data %><span class="pill plain">data</span><% end %><span class="pill <%= users.zero? ? "warn" : "plain" %>"><%= users %> <%= users == 1 ? "agent" : "agents" %></span></div>
39
+ </a>
40
+ <% end %>
41
+ </details>
28
42
  <% end %>
29
- <div class="drill-group-label">Agents · allow / deny</div>
30
- <% @agents.each do |a| %>
31
- <a class="drill-item<%== ' active' if @sel_agent && @sel_agent.id == a.id %>"
32
- href="/studio/tools?a=<%= Rack::Utils.escape(a.id) %>"
33
- data-turbo-frame="tool-detail" data-turbo-action="advance"
34
- data-list-filter-target="item" data-filter-text="<%= a.id %>"
35
- data-active-link-target="item" data-action="click->active-link#select">
36
- <div class="drill-item-name"><span class="txt" title="<%= a.id %>"><%= a.id %></span>
37
- <span class="pill plain"><%= a.tools_allow.nil? ? "all" : "#{tools_on_count(a, @tools)}/#{@tools.size}" %></span>
38
- </div>
39
- </a>
43
+ <% if @dropped_tool_names.any? %>
44
+ <details class="drill-group" data-list-filter-target="group" open>
45
+ <summary class="drill-group-label">Invalid definitions <span class="count"><%= @dropped_tool_names.size %></span></summary>
46
+ <% @dropped_tool_names.sort.each do |name| %>
47
+ <a class="drill-item drill-item-sm" href="/studio/tools/def/<%= Rack::Utils.escape(name) %>"
48
+ data-turbo-frame="tool-detail" data-turbo-action="advance"
49
+ data-list-filter-target="item" data-filter-text="<%= name %>"
50
+ title="its definition is invalid — no agent can call it until it is fixed">
51
+ <div class="drill-item-name"><span class="txt"><%= name %></span><span class="pill locked">dropped</span></div>
52
+ </a>
53
+ <% end %>
54
+ </details>
40
55
  <% end %>
41
- <div class="empty" data-list-filter-target="empty" hidden>No agents match your filter.</div>
56
+ <div class="empty" data-list-filter-target="empty" hidden>No tools match your search.</div>
42
57
  </nav>
43
58
  </aside>
44
59
  <% end %>
@@ -47,54 +62,28 @@
47
62
  <% if flash["error"] %><div class="flash err" role="alert"><%= flash["error"] %></div><% end %>
48
63
  <% if flash["notice"] %><div class="flash ok" role="status"><%= flash["notice"] %></div><% end %>
49
64
  <% end %>
50
- <% if @sel_agent.nil? %>
51
- <div class="drill-empty">
52
- <div>
53
- <h2>Tools</h2>
54
- <p>Pick an agent to set which tools it can call — or edit a data tool on the left. <strong>all</strong> = open allowlist (every tool flows through); a <span class="pill locked">locked</span> tool is on the denylist (deny always wins).</p>
55
- </div>
56
- </div>
57
- <% else %>
58
- <% a = @sel_agent %>
65
+ <% case @detail %>
66
+ <% when :edit %>
67
+ <%== render("_tool_edit") %>
68
+ <% when :info %>
69
+ <% t = @info_tool %>
59
70
  <div class="drill-pane-head">
60
71
  <div class="title">
61
- <strong class="mono"><%= a.id %></strong>
72
+ <strong class="mono"><%= t.name %></strong>
73
+ <span class="count"><%= @info_label %></span>
62
74
  </div>
63
- <button type="submit" form="tools-form" class="btn primary" data-turbo-submits-with="Saving…">Save tools</button>
64
75
  </div>
65
- <div class="drill-pane-body" data-controller="toggle-counter">
66
- <div class="matrix-topline">
67
- <span class="count-badge mono" data-toggle-counter-target="count"><%= a.tools_allow.nil? ? "all" : "#{tools_on_count(a, @tools)}/#{@tools.size} on" %></span>
68
- <label class="switch" title="open allowlist — every tool flows through">
69
- <input type="checkbox" name="all_tools" value="1" form="tools-form"<%== " checked" if a.tools_allow.nil? %>
70
- data-toggle-counter-target="all" data-action="change->toggle-counter#update">
71
- <span class="switch-track" aria-hidden="true"></span>
72
- <span class="switch-label">all tools</span>
73
- </label>
74
- <button type="button" class="btn ghost btn-sm" data-action="click->toggle-counter#selectNone"
75
- title="uncheck every tool (deny-all — Save tools to apply)">none</button>
76
+ <div class="drill-pane-body">
77
+ <p><%= t.description %></p>
78
+ <p class="muted"><%= t.plugin.to_s.start_with?("mcp:") ? "Comes from an MCP server; edit the server under MCP." : "Built into the engine; it cannot be edited here." %></p>
79
+ <%== render("_tool_users", locals: { name: t.name }) %>
80
+ </div>
81
+ <% else %>
82
+ <div class="drill-empty">
83
+ <div>
84
+ <h2>Tools</h2>
85
+ <p>Pick a tool to see which agents can call it. HTTP tools open their editor; MCP and built-in tools are read-only. Which tools an agent may call is set on the agent, in its <strong>Tools</strong> tab.</p>
76
86
  </div>
77
- <p class="muted">Checked tools are allowed next turn. A <span class="pill locked">locked</span> tool is on the denylist — deny always wins, so it can't be granted here.</p>
78
- <form id="tools-form" method="post" action="/studio/tools/<%= Rack::Utils.escape(a.id) %>">
79
- <%== csrf_tag %>
80
- <% if @tools.empty? %>
81
- <p class="muted">No tools registered in the runtime.</p>
82
- <% else %>
83
- <div class="check-grid tool-grid<%== " is-open" if a.tools_allow.nil? %>" data-toggle-counter-target="grid">
84
- <% @tools.each do |t| %>
85
- <% denied = tool_denied_for?(a, t.name) %>
86
- <label class="check tool-check<%== " locked" if denied %>" title="<%= denied ? "denied — deny wins" : t.description %>">
87
- <input type="checkbox" name="tools[]" value="<%= t.name %>"
88
- <%== " checked" if tool_allowed_for?(a, t.name) && !denied %>
89
- <%== " disabled" if denied %>
90
- data-toggle-counter-target="tool" data-locked="<%= denied %>"
91
- data-action="change->toggle-counter#update">
92
- <span class="mono"><%= t.name %></span><% if @data_tool_names.include?(t.name) %> <span class="pill plain">data</span><% end %><% if denied %> <span class="pill locked">locked</span><% end %>
93
- </label>
94
- <% end %>
95
- </div>
96
- <% end %>
97
- </form>
98
87
  </div>
99
88
  <% end %>
100
89
  </turbo-frame>
@@ -41,8 +41,11 @@ module Insika
41
41
  end
42
42
 
43
43
  # Runs the block on an Async fiber CHILD of the parent. Returns the Task.
44
+ # The fiber yields nil: Async keeps a finished task linked to the siblings that
45
+ # follow it (List#remove! leaves `tail`), so one stale reference to an old turn
46
+ # keeps every later one reachable — with its result, unless the result is nil.
44
47
  def run(&turn_block)
45
- @async_task = @parent.async { turn_block.call(self) }
48
+ @async_task = @parent.async { turn_block.call(self); nil }
46
49
  end
47
50
 
48
51
  # Drains the mailbox WITHOUT blocking (boundaries). `:cancel` raises (the top
@@ -117,6 +117,48 @@ module Insika
117
117
  to_task(record)
118
118
  end
119
119
 
120
+ # -> Task; begin_execution + the move to :running in ONE write and one
121
+ # transaction (the dispatch claim, like transition). Only queued, paused and
122
+ # waiting move to :running; any other status (a recovered orphan already
123
+ # :running) keeps its own, exactly as the two separate calls did.
124
+ def start_execution(id)
125
+ @store.transaction do
126
+ record = fetch!(id)
127
+ raise ArgumentError, "an open Execution already exists on task #{id}" if open_execution(record)
128
+
129
+ record["status"] = "running" if %w[queued paused waiting].include?(record["status"])
130
+ record["executions"] += [{ "attempt" => record["executions"].size + 1, "started_at" => timestamp,
131
+ "finished_at" => nil, "outcome" => nil, "error" => nil }]
132
+ record["updated_at"] = timestamp
133
+ @store.set(SCOPE, key_for(id), record)
134
+ to_task(record)
135
+ end
136
+ end
137
+
138
+ # -> Task; finish_execution + transition(to: outcome) in ONE write and one
139
+ # transaction. ArgumentError (nothing written) for no open Execution or an
140
+ # invalid transition.
141
+ def complete_execution(id, outcome:)
142
+ target = outcome.to_sym
143
+ raise ArgumentError, "invalid status: #{outcome}" unless STATUSES.include?(target)
144
+
145
+ @store.transaction do
146
+ record = fetch!(id)
147
+ open = open_execution(record)
148
+ raise ArgumentError, "no open Execution on task #{id}" if open.nil?
149
+
150
+ from = record["status"].to_sym
151
+ raise ArgumentError, "invalid transition: #{from} -> #{target}" unless TRANSITIONS.fetch(from).include?(target)
152
+
153
+ open["finished_at"] = timestamp
154
+ open["outcome"] = target.to_s
155
+ record["status"] = target.to_s
156
+ record["updated_at"] = timestamp
157
+ @store.set(SCOPE, key_for(id), record)
158
+ to_task(record)
159
+ end
160
+ end
161
+
120
162
  # -> Task; closes the current Execution. ArgumentError if none is open.
121
163
  # Does NOT touch status (that is transition's job).
122
164
  def finish_execution(id, outcome:)
@@ -189,6 +231,20 @@ module Insika
189
231
  def queued = with_status(:queued)
190
232
 
191
233
  # -> enumerates ids without the "task:" prefix; without a block returns an Enumerator.
234
+ # -> [Task] every record, from ONE bulk read of the backend (see
235
+ # Store#entries) instead of a read per id.
236
+ def all
237
+ @store.entries(SCOPE, KEY_PREFIX).map { |_, record| to_task(record) }
238
+ end
239
+
240
+ # -> [Task] one page, most recently written first (every status change
241
+ # rewrites the task, so this is "most recently updated"). Reads only the page.
242
+ def recent(limit, offset: 0)
243
+ # offset only when paging: a backend written for the 3-argument #recent keeps working
244
+ rows = offset.zero? ? @store.recent(SCOPE, KEY_PREFIX, limit) : @store.recent(SCOPE, KEY_PREFIX, limit, offset)
245
+ rows.map { |_, record| to_task(record) }
246
+ end
247
+
192
248
  def each_id
193
249
  return enum_for(:each_id) unless block_given?
194
250
 
@@ -201,7 +257,17 @@ module Insika
201
257
  # tasks go with it, terminal tasks only (a live :running task must never
202
258
  # be deleted under its own fiber).
203
259
  def delete(id)
204
- @store.delete(SCOPE, key_for(id))
260
+ @store.transaction do
261
+ # Read the task first: the trace recorders read it before writing, so on a
262
+ # backend that locks per key read this orders a purge against them.
263
+ @store.get(SCOPE, key_for(id))
264
+ @store.list(ModelMetricsStore::SCOPE, ModelMetricsStore.task_prefix(id)).each do |key|
265
+ @store.delete(ModelMetricsStore::SCOPE, key)
266
+ end
267
+ @store.delete(LLMTraceStore::SCOPE, id.to_s)
268
+ @store.delete(ModelMetricsStore::TURN_SCOPE, id.to_s)
269
+ @store.delete(SCOPE, key_for(id))
270
+ end
205
271
  end
206
272
 
207
273
  private
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../env_schema"
4
5
 
5
6
  module Insika
6
7
  module Telemetry
@@ -22,9 +23,9 @@ module Insika
22
23
  # nothing is emitted and the dashboard shows a gap instead of a lie.
23
24
  #
24
25
  # Token accounting (matches `Executor#usage_of`, which mirrors the providers):
25
- # `cached_tokens` is a SUBSET of `input_tokens`, `cache_creation_tokens` is not.
26
- # - `cached_input` given -> cached tokens are billed at that rate and
27
- # subtracted from the fresh input; absent -> they stay at the input rate.
26
+ # All buckets are disjoint, matching RubyLLM::Tokens.
27
+ # - `cached_input` given -> cached tokens are billed at that rate;
28
+ # absent -> they use the input rate.
28
29
  # - `cache_write` given -> cache-creation tokens billed at that rate; absent
29
30
  # -> at the input rate (they are input tokens the provider did write).
30
31
  class Pricing
@@ -47,21 +48,22 @@ module Insika
47
48
 
48
49
  rate = rate_for(usage[:model]) or return nil
49
50
 
50
- input = usage[:input_tokens].to_i
51
- cached = usage[:cached_tokens].to_i
52
- written = usage[:cache_creation_tokens].to_i
53
- output = usage[:output_tokens].to_i
51
+ buckets = [[usage[:input_tokens], rate["input"]],
52
+ [usage[:output_tokens], rate["output"]],
53
+ [usage[:cached_tokens], rate["cached_input"] || rate["input"]],
54
+ [usage[:cache_creation_tokens], rate["cache_write"] || rate["input"]]]
55
+ return nil if buckets.all? { |count, _| count.nil? }
56
+ return nil if buckets.any? { |count, price| count.to_i.positive? && price.nil? }
54
57
 
55
- cached_rate = rate["cached_input"]
56
- fresh = cached_rate ? [input - cached, 0].max : input
57
-
58
- millionths = fresh * rate["input"].to_f +
59
- (cached_rate ? cached * cached_rate.to_f : 0.0) +
60
- written * (rate["cache_write"] || rate["input"]).to_f +
61
- output * rate["output"].to_f
58
+ millionths = buckets.sum { |count, price| count.to_i * price.to_f }
62
59
  (millionths / 1_000_000.0).round(PRECISION)
63
60
  end
64
61
 
62
+ # The operator's table from INSIKA_MODEL_PRICING (empty when unset): the ONE
63
+ # cost rule Studio, traces and metrics share — the cost the provider library
64
+ # reported, and these rates only for what it could not price.
65
+ def self.from_env(env = ENV) = parse(Insika::EnvSchema.read("INSIKA_MODEL_PRICING", env))
66
+
65
67
  # Parses the operator's table. Accepts a JSON object; anything else (blank,
66
68
  # malformed, not an object) -> an EMPTY Pricing, never an exception: telemetry
67
69
  # config must not be able to stop a boot.
@@ -18,8 +18,8 @@ module Insika
18
18
  # a deliberate LOW-CARDINALITY subset of the span attributes — never task_id or
19
19
  # session_id. `meter:` nil -> spans only (the metrics SDK is optional).
20
20
  #
21
- # `pricing:` nil -> no cost attribute/metric. Cost is an ESTIMATE from an
22
- # operator-declared rates table (see Pricing) — the engine ships no prices.
21
+ # Native attempt-time cost is the default. An operator-declared rates table
22
+ # can override it with a negotiated-price estimate (see Pricing).
23
23
  #
24
24
  # PURE/testable: talks to a DUCK-TYPED `tracer` (start_span/set_attribute/
25
25
  # record_error/finish) and `meter` (create_counter/create_histogram -> add/
@@ -29,7 +29,7 @@ module Insika
29
29
  # Robust: `record` NEVER raises (telemetry doesn't bring down a turn). Timestamps
30
30
  # come from each event's `meta.at` (spans reconstructed with real time).
31
31
  class Recorder
32
- Turn = Struct.new(:span, :tools, :labels, :start) # tools = FIFO queue of open tools
32
+ Turn = Struct.new(:span, :tools, :labels, :start, :requests, :requests_truncated) # tools = FIFO queue of open tools
33
33
  OpenTool = Struct.new(:span, :name, :start)
34
34
 
35
35
  # Ceiling of open turns: a kill -9 without a terminal event would leave the turn
@@ -41,9 +41,11 @@ module Insika
41
41
  # renaming one breaks every dashboard built on it.
42
42
  class Instruments
43
43
  attr_reader :turns, :turn_duration, :tokens, :cost, :tool_calls, :tool_duration,
44
- :cache_hit_rate, :loop_intervened, :context_compacted
44
+ :cache_hit_rate, :loop_intervened, :context_compacted, :tool_blocked, :confirmations, :llm_attempts, :llm_failures
45
45
 
46
46
  def initialize(meter)
47
+ @llm_attempts = meter.create_counter("insika.llm.attempts", unit: "{attempt}", description: "Native model attempts")
48
+ @llm_failures = meter.create_counter("insika.llm.failures", unit: "{attempt}", description: "Failed native model attempts")
47
49
  @turns = meter.create_counter("insika.turns", unit: "{turn}",
48
50
  description: "Turns completed, by outcome")
49
51
  @turn_duration = meter.create_histogram("insika.turn.duration", unit: "s",
@@ -60,6 +62,10 @@ module Insika
60
62
  description: "Prompt-cache hit rate of a turn (cached / billed prompt tokens)")
61
63
  @loop_intervened = meter.create_counter("insika.tool.loop_intervened", unit: "{intervention}",
62
64
  description: "Loop-detector warnings delivered to the model")
65
+ @tool_blocked = meter.create_counter("insika.tool.blocked", unit: "{call}",
66
+ description: "Tool calls blocked before execution")
67
+ @confirmations = meter.create_counter("insika.confirmation", unit: "{hold}",
68
+ description: "Calls held for the customer's confirmation, by outcome")
63
69
  @context_compacted = meter.create_counter("insika.context.compacted", unit: "{compaction}",
64
70
  description: "In-session compactions persisted (RFC-0044)")
65
71
  end
@@ -77,10 +83,16 @@ module Insika
77
83
  data = event.data || {}
78
84
  case event.type
79
85
  when :task_started then start_turn(meta, data)
86
+ when :llm_request, :llm_usage then model_event(meta, data, event.type)
80
87
  when :tool_call then start_tool(meta, data)
81
88
  when :tool_result then finish_tool(meta)
82
89
  when :data_tool_call then point_tool(meta, data)
83
90
  when :tool_loop_intervened then count_loop(meta, data)
91
+ when :tool_blocked then count_blocked(meta, data)
92
+ when :confirmation_requested then count_confirmation(meta, data, "requested")
93
+ when :confirmation_confirmed then count_confirmation(meta, data, "confirmed")
94
+ when :confirmation_cancelled then count_confirmation(meta, data, "cancelled")
95
+ when :confirmation_expired then count_confirmation(meta, data, "expired")
84
96
  when :context_compacted then count_compaction(data)
85
97
  when :task_completed then finish_turn(meta, data, :ok)
86
98
  when :task_failed then finish_turn(meta, data, :error)
@@ -105,7 +117,7 @@ module Insika
105
117
  "insika.turn", parent: nil, start_time: at,
106
118
  attributes: labels.merge(attrs("insika.task_id" => id, "insika.session_id" => meta[:session_id]))
107
119
  )
108
- @turns[id] = Turn.new(span, [], labels, at)
120
+ @turns[id] = Turn.new(span, [], labels, at, [], false)
109
121
  end
110
122
 
111
123
  def start_tool(meta, data)
@@ -144,11 +156,80 @@ module Insika
144
156
  at = ts(meta[:at])
145
157
  usage = data[:usage]
146
158
  set_usage(turn.span, usage)
159
+ rerank_cost = rerank_cost(turn)
160
+ if rerank_cost
161
+ turn.span.set_attribute("insika.cost.rerank_usd", rerank_cost)
162
+ chat_cost = estimated_cost(usage)
163
+ turn.span.set_attribute("insika.cost.total_usd", chat_cost + rerank_cost) if chat_cost
164
+ end
147
165
  turn.span.set_attribute("insika.status", status.to_s)
148
- turn.span.record_error(data[:message].to_s) if status == :error
166
+ turn.span.record_error(exception_class(data[:error])) if status == :error
167
+ finish_requests(turn)
149
168
  turn.tools.each { |t| t.span.finish(end_time: at) } # orphans (failure mid-way)
150
169
  turn.span.finish(end_time: at)
151
- count_turn(turn, usage, status.to_s, elapsed(turn.start, at))
170
+ count_turn(turn, usage, status.to_s, elapsed(turn.start, at), rerank_cost)
171
+ end
172
+
173
+ def rerank_cost(turn)
174
+ return nil if turn.requests_truncated
175
+
176
+ requests = turn.requests.select { |entry| entry["type"] == "llm_request" && entry["operation"] == "rerank" }
177
+ attempts = turn.requests.select { |entry| entry["type"] == "llm_usage" && entry["operation"] == "rerank" }
178
+ return nil if attempts.empty? || attempts.any? { |entry| !entry["cost"].is_a?(Numeric) }
179
+ return nil if requests.any? { |request| attempts.none? { |entry| entry["request_id"] == request["request_id"] } }
180
+
181
+ attempts.sum { |entry| entry["cost"] }
182
+ end
183
+
184
+ def exception_class(value)
185
+ value.is_a?(String) && value.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/) ? value[0, 256] : "Task failed"
186
+ end
187
+
188
+ def model_event(meta, data, type)
189
+ turn = @turns[meta[:task_id]] or return
190
+ entry = LLMTraceStore.sanitize(data.merge("type" => type.to_s, "at" => meta[:at]))
191
+ turn.requests << entry
192
+ if turn.requests.size > LLMTraceStore::MAX_PER_TASK
193
+ turn.requests.shift
194
+ turn.requests_truncated = true
195
+ end
196
+ return unless @instruments && type == :llm_usage
197
+
198
+ labels = turn.labels.merge(attrs("insika.model" => entry["model"], "insika.provider" => entry["provider"],
199
+ "insika.operation" => entry["operation"], "insika.status" => entry["status"]))
200
+ @instruments.llm_attempts.add(1, attributes: labels)
201
+ @instruments.llm_failures.add(1, attributes: labels) if entry["status"] == "failed"
202
+ end
203
+
204
+ # Success usage can arrive after request completion. Reconstruct at the turn
205
+ # terminal, so exported spans include late usage without modifying closed spans.
206
+ def finish_requests(turn)
207
+ turn.span.set_attribute("insika.llm.truncated", true) if turn.requests_truncated
208
+ turn.requests.group_by { |entry| entry["request_id"] }.each do |id, entries|
209
+ next if id.nil? || id.empty?
210
+ request = entries.find { |entry| entry["type"] == "llm_request" }
211
+ next unless request && request["duration_ms"]
212
+
213
+ at = ts(request["at"])
214
+ next unless at
215
+ attempts = entries.select { |entry| entry["type"] == "llm_usage" }
216
+ attributes = attrs("insika.llm.request_id" => id, "insika.model" => request["model"],
217
+ "insika.provider" => request["provider"], "insika.operation" => request["operation"],
218
+ "insika.status" => request["status"], "insika.llm.duration_ms" => request["duration_ms"],
219
+ "insika.llm.truncated" => turn.requests_truncated,
220
+ "insika.llm.attempts" => attempts.size, "insika.llm.failures" => attempts.count { |e| e["status"] == "failed" })
221
+ attempts.each_with_index do |attempt, index|
222
+ (%w[status] + LLMTraceStore::NUMBER_FIELDS - %w[turn duration_ms]).each do |key|
223
+ attributes["insika.llm.attempt.#{index + 1}.#{key}"] = attempt[key] unless attempt[key].nil?
224
+ end
225
+ end
226
+ span = @tracer.start_span("insika.llm.request", parent: turn.span,
227
+ start_time: at - request["duration_ms"] / 1000.0, attributes: attributes)
228
+ span.record_error(exception_class(request["exception_class"])) if request["status"] == "failed"
229
+ span.finish(end_time: at)
230
+ rescue StandardError
231
+ # A failed exporter must not prevent remaining requests or the turn closing.
232
+ end
152
233
  end
153
234
 
154
235
  def set_usage(span, usage)
@@ -161,13 +242,14 @@ module Insika
161
242
  span.set_attribute("insika.tokens.cache_creation", usage[:cache_creation_tokens]) if usage[:cache_creation_tokens]
162
243
  span.set_attribute("insika.model", usage[:model].to_s) if usage[:model]
163
244
  span.set_attribute("insika.model_source", usage[:model_source].to_s) if usage[:model_source]
245
+ span.set_attribute("insika.provider", usage[:provider].to_s) if usage[:provider]
164
246
  cost = estimated_cost(usage)
165
247
  span.set_attribute("insika.cost.usd", cost) if cost
166
248
  end
167
249
 
168
250
  # --- metrics (no-op when no meter was injected) ------------------------
169
251
 
170
- def count_turn(turn, usage, status, seconds)
252
+ def count_turn(turn, usage, status, seconds, rerank_cost = nil)
171
253
  return unless @instruments
172
254
 
173
255
  model = usage && usage[:model]
@@ -175,6 +257,7 @@ module Insika
175
257
  @instruments.turns.add(1, attributes: labels)
176
258
  @instruments.turn_duration.record(seconds, attributes: labels) if seconds
177
259
  count_usage(turn, usage)
260
+ @instruments.cost.add(rerank_cost, attributes: turn.labels.merge("insika.operation" => "rerank")) if rerank_cost
178
261
  count_cache_hit(turn, usage)
179
262
  end
180
263
 
@@ -206,6 +289,26 @@ module Insika
206
289
  @instruments.loop_intervened.add(1, attributes: labels)
207
290
  end
208
291
 
292
+ # One counter for the whole life of a customer hold; `outcome` tells the
293
+ # four moments apart, so requested minus the other three is what is open.
294
+ def count_confirmation(meta, data, outcome)
295
+ return unless @instruments
296
+
297
+ turn = @turns[meta[:task_id]]
298
+ labels = (turn ? turn.labels : {}).merge(attrs("insika.tool" => data[:tool]&.to_s,
299
+ "insika.outcome" => outcome))
300
+ @instruments.confirmations.add(1, attributes: labels)
301
+ end
302
+
303
+ def count_blocked(meta, data)
304
+ return unless @instruments
305
+
306
+ turn = @turns[meta[:task_id]] or return
307
+ labels = turn.labels.merge(attrs("insika.tool" => data[:name]&.to_s,
308
+ "insika.gate" => data[:gate]&.to_s))
309
+ @instruments.tool_blocked.add(1, attributes: labels)
310
+ end
311
+
209
312
  # A compaction persisted (`:context_compacted`, RFC-0044) — counted by
210
313
  # agent/model, INDEPENDENT of any open turn: it fires post-turn, usually
211
314
  # after task_completed already closed the span.
@@ -238,7 +341,9 @@ module Insika
238
341
  @instruments.tool_duration.record(seconds, attributes: labels) if seconds
239
342
  end
240
343
 
241
- def estimated_cost(usage) = @pricing&.cost(usage)
344
+ # Turn usage arrives already priced per response (Executor#usage_of applies
345
+ # the same rule), so its cost_usd wins; the table covers usage that was not.
346
+ def estimated_cost(usage) = usage&.fetch(:cost_usd, nil) || @pricing&.cost(usage)
242
347
 
243
348
  # -----------------------------------------------------------------------
244
349
 
@@ -248,6 +353,7 @@ module Insika
248
353
 
249
354
  turn.tools.each { |t| t.span.finish(end_time: nil) }
250
355
  turn.span.set_attribute("insika.status", "abandoned")
356
+ finish_requests(turn)
251
357
  turn.span.finish(end_time: nil)
252
358
  count_turn(turn, nil, "abandoned", nil)
253
359
  end