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
@@ -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, :tool_blocked
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",
@@ -62,6 +64,8 @@ module Insika
62
64
  description: "Loop-detector warnings delivered to the model")
63
65
  @tool_blocked = meter.create_counter("insika.tool.blocked", unit: "{call}",
64
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")
65
69
  @context_compacted = meter.create_counter("insika.context.compacted", unit: "{compaction}",
66
70
  description: "In-session compactions persisted (RFC-0044)")
67
71
  end
@@ -79,11 +83,16 @@ module Insika
79
83
  data = event.data || {}
80
84
  case event.type
81
85
  when :task_started then start_turn(meta, data)
86
+ when :llm_request, :llm_usage then model_event(meta, data, event.type)
82
87
  when :tool_call then start_tool(meta, data)
83
88
  when :tool_result then finish_tool(meta)
84
89
  when :data_tool_call then point_tool(meta, data)
85
90
  when :tool_loop_intervened then count_loop(meta, data)
86
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")
87
96
  when :context_compacted then count_compaction(data)
88
97
  when :task_completed then finish_turn(meta, data, :ok)
89
98
  when :task_failed then finish_turn(meta, data, :error)
@@ -108,7 +117,7 @@ module Insika
108
117
  "insika.turn", parent: nil, start_time: at,
109
118
  attributes: labels.merge(attrs("insika.task_id" => id, "insika.session_id" => meta[:session_id]))
110
119
  )
111
- @turns[id] = Turn.new(span, [], labels, at)
120
+ @turns[id] = Turn.new(span, [], labels, at, [], false)
112
121
  end
113
122
 
114
123
  def start_tool(meta, data)
@@ -147,11 +156,80 @@ module Insika
147
156
  at = ts(meta[:at])
148
157
  usage = data[:usage]
149
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
150
165
  turn.span.set_attribute("insika.status", status.to_s)
151
- 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)
152
168
  turn.tools.each { |t| t.span.finish(end_time: at) } # orphans (failure mid-way)
153
169
  turn.span.finish(end_time: at)
154
- 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
155
233
  end
156
234
 
157
235
  def set_usage(span, usage)
@@ -164,13 +242,14 @@ module Insika
164
242
  span.set_attribute("insika.tokens.cache_creation", usage[:cache_creation_tokens]) if usage[:cache_creation_tokens]
165
243
  span.set_attribute("insika.model", usage[:model].to_s) if usage[:model]
166
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]
167
246
  cost = estimated_cost(usage)
168
247
  span.set_attribute("insika.cost.usd", cost) if cost
169
248
  end
170
249
 
171
250
  # --- metrics (no-op when no meter was injected) ------------------------
172
251
 
173
- def count_turn(turn, usage, status, seconds)
252
+ def count_turn(turn, usage, status, seconds, rerank_cost = nil)
174
253
  return unless @instruments
175
254
 
176
255
  model = usage && usage[:model]
@@ -178,6 +257,7 @@ module Insika
178
257
  @instruments.turns.add(1, attributes: labels)
179
258
  @instruments.turn_duration.record(seconds, attributes: labels) if seconds
180
259
  count_usage(turn, usage)
260
+ @instruments.cost.add(rerank_cost, attributes: turn.labels.merge("insika.operation" => "rerank")) if rerank_cost
181
261
  count_cache_hit(turn, usage)
182
262
  end
183
263
 
@@ -209,6 +289,17 @@ module Insika
209
289
  @instruments.loop_intervened.add(1, attributes: labels)
210
290
  end
211
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
+
212
303
  def count_blocked(meta, data)
213
304
  return unless @instruments
214
305
 
@@ -250,7 +341,9 @@ module Insika
250
341
  @instruments.tool_duration.record(seconds, attributes: labels) if seconds
251
342
  end
252
343
 
253
- 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)
254
347
 
255
348
  # -----------------------------------------------------------------------
256
349
 
@@ -260,6 +353,7 @@ module Insika
260
353
 
261
354
  turn.tools.each { |t| t.span.finish(end_time: nil) }
262
355
  turn.span.set_attribute("insika.status", "abandoned")
356
+ finish_requests(turn)
263
357
  turn.span.finish(end_time: nil)
264
358
  count_turn(turn, nil, "abandoned", nil)
265
359
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Insika
6
+ module Telemetry
7
+ # One instance belongs to one chat context, never to a graph or current fiber.
8
+ class RubyLLMInstrumenter
9
+ def initialize(emit:, delegate: nil, operation: nil, model: nil, pricing: nil)
10
+ @emit, @delegate, @operation, @model, @pricing = emit, delegate, operation, model, pricing
11
+ end
12
+
13
+ def instrument(name, payload = {}, &block)
14
+ return chat(payload, &block) if name == "chat.ruby_llm" && block
15
+ return request(payload, &block) if name == "request.ruby_llm" && block
16
+
17
+ result = delegate(name, payload, &block)
18
+ record(:llm_usage, payload) if name == "usage.ruby_llm"
19
+ result
20
+ end
21
+
22
+ private
23
+
24
+ def chat(payload, &block)
25
+ previous_model, previous_request = @model, @request_id
26
+ @model, @request_id = scalar(payload[:model]), nil
27
+ delegate("chat.ruby_llm", payload, &block)
28
+ ensure
29
+ @model, @request_id = previous_model, previous_request
30
+ end
31
+
32
+ def request(payload, &block)
33
+ @request_id = SecureRandom.uuid
34
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ error = nil
36
+ begin
37
+ delegate("request.ruby_llm", payload, &block)
38
+ rescue Exception => error # Preserve cancellation and the original model exception too.
39
+ raise
40
+ ensure
41
+ record(:llm_request, payload, error: error,
42
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000)
43
+ end
44
+ end
45
+
46
+ # Host notifications still wrap the operation. A broken subscriber must not
47
+ # retry the model, replace its result, or swallow/replace its exception.
48
+ def delegate(name, payload, &block)
49
+ return block&.call(payload) unless @delegate.respond_to?(:instrument)
50
+
51
+ called = false
52
+ result = error = nil
53
+ operation = proc do |*args|
54
+ called = true
55
+ begin
56
+ result = block.call(*args)
57
+ rescue Exception => caught
58
+ error = caught
59
+ raise
60
+ end
61
+ end
62
+ begin
63
+ block ? @delegate.instrument(name, payload, &operation) : @delegate.instrument(name, payload)
64
+ rescue StandardError
65
+ # Telemetry is best effort; the operation's outcome is authoritative.
66
+ end
67
+ raise error if error
68
+ return unless block
69
+
70
+ called ? result : block.call(payload)
71
+ end
72
+
73
+ def record(type, payload, error: nil, duration_ms: nil)
74
+ data = {
75
+ "operation" => scalar(payload[:operation] || @operation),
76
+ "provider" => scalar(payload[:provider]),
77
+ "model" => scalar(payload[:model] || @model),
78
+ "status" => type == :llm_request ? (error ? "failed" : "succeeded") : scalar(payload[:status]),
79
+ "request_id" => @request_id
80
+ }
81
+ if type == :llm_request
82
+ data["duration_ms"] = duration_ms
83
+ data["exception_class"] = error.class.name if error
84
+ else
85
+ %i[input output cache_read cache_write thinking].each do |field|
86
+ value = payload[:tokens]&.public_send(field)
87
+ data["#{field}_tokens"] = value.is_a?(Numeric) ? value : nil
88
+ end
89
+ value = payload[:cost]&.total
90
+ value = priced(data) unless value.is_a?(Numeric)
91
+ data["cost"] = value.is_a?(Numeric) ? value : nil
92
+ end
93
+ @emit.call(type, data)
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
98
+ # The operator's rates fill in only what the library could not price (Pricing.from_env).
99
+ def priced(data)
100
+ @pricing&.cost(model: data["model"], input_tokens: data["input_tokens"],
101
+ output_tokens: data["output_tokens"], cached_tokens: data["cache_read_tokens"],
102
+ cache_creation_tokens: data["cache_write_tokens"])
103
+ end
104
+
105
+ def scalar(value)
106
+ value.to_s if value.is_a?(String) || value.is_a?(Symbol)
107
+ end
108
+ end
109
+ end
110
+ end