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
data/docs/EVALS.md CHANGED
@@ -42,6 +42,117 @@ expect:
42
42
  Turns replay **in order** under one conversation, so a case can build context ("what
43
43
  about the shipping?" after "I want the 70% bar"); the assertions run on the last turn.
44
44
 
45
+ ### State — a case starts from a snapshot
46
+
47
+ Every case above starts from an **empty** conversation. To test "the customer already
48
+ saw three products and says *add the second one*", such a case would first have to
49
+ replay the search turn — which makes it depend on the model's first answer, costs a
50
+ turn, and cannot reproduce a messy state (a contradiction from six turns ago, a
51
+ stored preference). `state:` is the precondition, loaded into the conversation
52
+ **before turn 1**:
53
+
54
+ ```yaml
55
+ id: loja-chocolates-add-seen
56
+ agent: loja-chocolates
57
+ requires:
58
+ tools: [add_to_cart]
59
+ state:
60
+ evidence:
61
+ ids: ["SKU-70-DARK"]
62
+ cards:
63
+ - { type: card, url: "https://shop.example/70", id: "SKU-70-DARK", caption: "Dark chocolate" }
64
+ memory: { facts: { preference: "dark chocolate" }, notes: [] }
65
+ history:
66
+ - { role: user, content: "quero chocolate amargo" }
67
+ - { role: assistant, content: "O chocolate 70% é SKU-70-DARK." }
68
+ briefing: { fields: { cep: "01311-000" } }
69
+ turns:
70
+ - user: "adiciona uma unidade desse chocolate 70% no carrinho"
71
+ expect:
72
+ tools_called: [add_to_cart]
73
+ never_calls: [search_products]
74
+ reply_omits: ["SKU-70-DARK"]
75
+ ```
76
+
77
+ Only those four `state` keys are accepted by the case loader. A persona case may
78
+ carry `state` too. Adapt the tool names, arguments and briefing fields to the agent.
79
+
80
+ The evidence ledger is a runtime precondition, not a list shown to the model:
81
+ include the relevant product and ID in `history` or the user's request. `cards`
82
+ seeds the cards a search would have returned (each needs a `url` and an `id`; the
83
+ id counts as seen), so a presentation case needs no lookup in the turn.
84
+
85
+ With the HTTP transport, the eval stays a **client**: the runner never writes a
86
+ store. Seeding goes through
87
+ `POST /v1/conversations/:id/seed` (same Bearer as the turn, same id namespacing for a
88
+ tenant), and the deployment accepts it **only while the platform setting
89
+ `evals.seeding` is on** — off by default, because a seeded conversation is a
90
+ fabricated precondition, and the doctor warns while it is on. With the setting off
91
+ the replay Runner **skips** seeded cases with a reason; it also skips when its
92
+ transport has no seed support. Other seed errors fail the replay. In-process
93
+ `GraphTransport` dispatches `seed_session` directly and bypasses `evals.seeding`;
94
+ it needs a runtime exposing the graph. The persona
95
+ Simulator surfaces seed refusal as an error instead of the Runner's skip result.
96
+
97
+ A session with messages returns `409`. Replays default to `eval-<case-id>`; pass
98
+ `--conv-map FILE` with a JSON mapping such as
99
+ `{"loja-chocolates-add-seen":"eval-add-seen-run-2"}` to use a fresh ID on reruns.
100
+ Seeded memory may share a tenant cell even with fresh IDs; see
101
+ [the seed API](API.md#seeding-an-eval-conversation) for scope and payload details.
102
+
103
+ #### Graders — what a turn's calls and reply are checked against
104
+
105
+ The turn's SSE stream reports each tool call with its arguments and how it ended
106
+ (`ok`, `error`, or `blocked` plus the gate that held it), so the deterministic
107
+ layer can check more than "was the tool called". All optional; each is its own
108
+ check, and the report names the one that failed.
109
+
110
+ | Key | Checks |
111
+ |-----|--------|
112
+ | `tools_called: [name, name?]` | each required tool was called on the LAST turn (`?` = optional, never fails) |
113
+ | `turns[i].tools_called: [names]` | the calls one specific turn must make — a two-turn case where turn one claimed an action and turn two repaired the store passes the key above and fails this one |
114
+ | `never_calls: [names]` | none of these was called — the negative every `tools_called` needs |
115
+ | `calls_one_of: [names]` | at least one of these was called |
116
+ | `first_tool: name` | the first call's name |
117
+ | `max_tool_calls: N` | a ceiling on the turn's calls |
118
+ | `reply_includes: [substrings]` | each appears in the published answer (case-insensitive) |
119
+ | `reply_omits: [substrings]` | none appears — where an internal id, a CPF or a raw tag leaking into the customer's text is pinned |
120
+ | `blocked_gates: ["tool:gate"]` | each pair appears among the turn's blocked calls |
121
+ | `ui_components: [names]` | a presentation tool showed at least one card of each component (the `insika.ui` frames) |
122
+ | `no_ui: true` | the turn showed nothing — the negative of `ui_components` |
123
+ | `must_not: [detectors]` | the negative detectors (`pii_leak`, `tool_error`, `phantom_action`, …); a blocked call is not a tool error |
124
+ | `claims: { tool: pattern }` | what `phantom_action` reads — per mutation, the words a reply uses to say it did it (a regex, case-insensitive); the store's language, so it lives in the case |
125
+
126
+ **Every positive has a negative.** A case that only says `tools_called: [add_to_cart]`
127
+ passes an agent that also re-searched, or that echoed the SKU to the customer. Pin
128
+ what a correct turn does *not* do — `never_calls`, `reply_omits`, `max_tool_calls` —
129
+ in the same case. Snapshots avoid the model calls needed to reconstruct the setup;
130
+ use the simulator when later customer messages need to branch on the replies.
131
+
132
+ `tools_called` confirms an attempted call, not a successful backend mutation.
133
+
134
+ `must_not: phantom_action` reads the other direction: a reply that SAYS it did
135
+ something ("adicionei", "removi", "fechei o pedido") on a turn where the matching
136
+ tool never ran, errored, or was blocked. It is cumulative over the conversation, so
137
+ "já adicionei" on turn two is true when turn one did it, and it only pins the words
138
+ against the calls — a cart that ended up right by accident is still a lie to the
139
+ customer. The words come from the case's `claims:` map, in the store's own
140
+ language, past tense on purpose: "quer adicionar?" is a question.
141
+
142
+ ```yaml
143
+ expect:
144
+ must_not: [tool_error, phantom_action]
145
+ claims:
146
+ add_to_cart: '\b(?:adicion(?:ei|ad[oa]s?)|coloquei)\b'
147
+ create_order: '\b(?:fechei|finalizei|pedido (?:criado|fechado))\b'
148
+ ```
149
+ Arguments are collected for inspection; there is no generic argument or cart-state
150
+ grader. `blocked_gates` needs completion statuses from the HTTP transport. The
151
+ in-process `GraphTransport` records tool names and UI events, but not completion
152
+ statuses or arguments. Neither transport counts `load_skill` / `load_knowledge` as
153
+ tool calls. `ui_components` requires a nonempty selection; `no_ui`
154
+ accepts absent UI events and events with zero items.
155
+
45
156
  ### `requires` — a case that cannot run here is skipped, not failed
46
157
 
47
158
  Deployments differ. Some stores have order tracking wired, some do not; some run
@@ -90,12 +201,13 @@ judge is told the rule instead of guessing it.
90
201
 
91
202
  ```yaml
92
203
  expect:
93
- policy: ask_once
204
+ policy: ask_once # the strict reading: one question, full stop
205
+ policy: { ask_once: { max: 2 } } # a store that greets its customers
94
206
  ```
95
207
 
96
208
  | policy | the check | the judge is told |
97
209
  |---|---|---|
98
- | `ask_once` | no reply asks more than one question | two questions in one message is a failure |
210
+ | `ask_once` | no reply asks more than `max` questions (default 1) | at most `max` per reply, and a greeting formula is courtesy rather than a question |
99
211
  | `investigate_first` | turn 1 asks something and calls no tool | ask on a vague request, don't search immediately |
100
212
  | `act_fast` | turn 1 calls a tool | asking what a search would answer is a failure |
101
213
 
@@ -108,6 +220,16 @@ so a tracking link's query string is not read as the agent asking something. It
108
220
  policy signal, not grammar — and crude was enough to catch an agent breaking a rule
109
221
  written in its own prompt, twice, with no model in the loop.
110
222
 
223
+ **`max` is the store's, and it matters more than it looks.** The default of 1 comes from
224
+ a real store's rule, quoted down to the punctuation: *"Máximo 1 pergunta principal por
225
+ mensagem = 1 ponto de interrogação no fim"*. A store that greets its customers spends
226
+ one of those on courtesy — measured across six harnesses answering the same "oi", `max:
227
+ 1` failed every reply containing "tudo bem?" and passed every reply without it, and
228
+ nothing else. That grades a greeting habit, not the rule the policy exists for, which is
229
+ the form: *"qual seu nome? qual o número do pedido? e o motivo?"*. Set `max` to what
230
+ your store actually tolerates; the judge is told the same number, so the two halves
231
+ never grade different rules.
232
+
111
233
  ### `reference` — compared against the system you want to replace
112
234
 
113
235
  `min_score: 0.7` says a reply cleared a bar you invented. It says nothing about
@@ -269,14 +391,11 @@ The model only sees `case_id`, enumerated with the ids the tool can actually run
269
391
  (never a free string it could invent) — every **simulated** case in the store, the same
270
392
  `persona:` shape as above.
271
393
 
272
- **Safety is derived here too, but there is no swap yet.** The tool computes the
273
- target's reachable side-effect tools (`Evals::EvalProfile`, the same derivation the CLI
274
- uses) and **refuses outright** — naming the tools — if that list is non-empty. Unlike
275
- the CLI, nothing here actually swaps a side-effect tool for a dry-run: `Evals::
276
- EvalProfile.registry` (the overlay) exists for exactly that, but nothing calls it yet.
277
- So `run_persona_eval` only runs against **read-only** target agents today; wiring the
278
- overlay into an in-process turn (so a target WITH a write tool can be tested safely) is
279
- follow-up work, not something this tool claims to do.
394
+ **Side effects are replaced for local graph runs.** The tool derives the target's
395
+ reachable side-effect tools and builds a throwaway executor with recorder tools
396
+ under those names. Read-only tools and the other graph collaborators are shared.
397
+ If side effects are reachable but no graph was supplied, it refuses the run.
398
+ This substitution prevents real writes; it does not prove a backend mutation.
280
399
 
281
400
  **Budget**: the persona model + judge model calls are the cost of running the eval,
282
401
  charged to the **calling** agent's own turn — never the target's (the target's own
data/docs/FACTS.md CHANGED
@@ -38,6 +38,10 @@ decides; the engine never applies its own proposal.
38
38
  5. Approved facts join the customer's memory cell and are injected by the
39
39
  Memory provider on the next turn of any session of that customer.
40
40
 
41
+ The extraction transcript contains only nonblank `user` and `assistant` text,
42
+ with original message indexes preserved. Tool payloads are excluded regardless
43
+ of `fencing`; an assistant's repetition of tool text can still be included.
44
+
41
45
  ## Enabling it — the `distill:` block
42
46
 
43
47
  Distillation is pack data on the agent, exactly like `refinement:` or
@@ -66,12 +70,14 @@ The engine never writes store vocabulary.
66
70
 
67
71
  The **Facts** page (operate group, next to Follow-ups) shows:
68
72
 
69
- - **Pending** — oldest first, because evidence ages. Each card is the fact,
70
- its confidence, its scope (`tenant:customer`), and the evidence excerpt read
71
- from the transcript at request time (evidence is a link, never a copy). Three
72
- buttons: **Approve & save to memory**, **Reject** (with an optional reason,
73
- shown on the card), **Dismiss** (ghost — labelled "will not be proposed
74
- again").
73
+ - **Pending** — oldest first, because evidence ages. Select a fact to review its
74
+ proposed value, model confidence and conversation evidence. Long excerpts scroll
75
+ within the evidence panel; **Open conversation** shows their source. Keys and
76
+ scope/session IDs live under **Technical details**. **Approve & save to memory**
77
+ saves the value; **Reject or dismiss** expands the alternatives, including an
78
+ optional rejection reason. Both prevent the same key and value from being proposed
79
+ again. Decisions refresh the queue and open the next pending fact in the selected
80
+ agent/scope.
75
81
  - **Stale** — the CAS-lost re-present (below): the proposed value struck
76
82
  through next to the operator's current value, resolved by dismissal.
77
83
  - **Recent** — every resolved proposal, most recent first, with operator and
data/docs/HARVEST.md CHANGED
@@ -35,8 +35,10 @@ A mine reads ONLY durable, finished data — never an in-flight turn, never a
35
35
  live prompt prefix (the "fork" is structural: the mining writes nothing to the
36
36
  sessions it read, so a customer turn's cache is untouched by construction). The
37
37
  miner gets the transcript slices (masked), the session's evidence-ledger ids,
38
- and the agent's current skill names so it does not re-propose them. Then the
39
- filters, each drop counted and logged:
38
+ the agent's current skill names and the candidates still open (name and
39
+ description), so it does not re-propose an idea under another name.
40
+ Simulator and eval sessions (`sim-…`, `eval-…`) are never mined: they are
41
+ not customers. Then the filters, each drop counted and logged:
40
42
 
41
43
  1. **The negative list** — the versioned rules file (seed, imported
42
44
  per store by `insika harvest:negative import --agent ID --file F`) and the
@@ -111,6 +113,9 @@ back, re-promoted is three readable rows.
111
113
  - `insika harvest:criterion check --file F` — strict-load the frozen conversion criterion (the hook before any promotion).
112
114
  - The **Harvest page** in the Studio: the human's inbox (each candidate with
113
115
  its evidence excerpt, the eval report, the conversion card, promote/reject),
114
- the gated-but-blocked rows with the named ruler hole, the pending list, the
116
+ the gated-but-blocked rows with the named ruler hole and a "Run checks
117
+ again" once the hole is fixed, the pending list (every row with the skill
118
+ text, the rationale and a link to its origin conversation), a notice when
119
+ the loop skips the agent (harvest off, or no grounding matcher), the
115
120
  append-only promoted log with the rollback mirror, the negative list with
116
121
  per-rule rejection counts, and the criterion block read-only.
data/docs/KNOWLEDGE.md CHANGED
@@ -27,6 +27,13 @@ and an operator can see, edit and resolve all of it in the Studio. Only the
27
27
  optional FTS5 index remains, deferred with a measured trigger — see
28
28
  [What's not here yet](#whats-not-here-yet).
29
29
 
30
+ Extraction reads only nonblank `user` and `assistant` text, retaining original
31
+ message indexes. Direct tool payloads are excluded regardless of `fencing`;
32
+ assistant paraphrases can still reach the extractor. The names and descriptions
33
+ in the injected `<knowledge>` block are sanitized when
34
+ [fencing](AGENTS.md#fencing--third-party-text-is-data-never-instructions) is on.
35
+ The full body returned by `load_knowledge` is not fenced.
36
+
30
37
  ## The concept format
31
38
 
32
39
  One concept is one record — a markdown document with a YAML frontmatter
@@ -88,6 +95,13 @@ end
88
95
  recalling (write-only, inspected by hand), or recall without learning
89
96
  (curate every concept by hand in the Studio, `provenance: policy`).
90
97
 
98
+ The same settings are available in **Studio → Agents → select an agent →
99
+ Configuration → Memory & knowledge**: learn from conversations, use knowledge in
100
+ replies, concepts per turn, concept types, learning model and instructions.
101
+ Knowledge reranking has its own switch and settings, independent of memory
102
+ reranking, and defaults to off. Options authored in a pack but not exposed in the
103
+ form, such as `index`, are preserved when saving.
104
+
91
105
  ## The write path
92
106
 
93
107
  After a turn completes, off the turn's critical path (the customer already has
data/docs/MEDIA.md CHANGED
@@ -39,6 +39,8 @@ curl -X POST /v1/messages?stream=false -H "Authorization: Bearer $TOKEN" \
39
39
  the usage flows like any ask. The first image URL is also
40
40
  `{{ctx.image_url}}` for data tools — photo analysis outside the prompt, the
41
41
  tool's own egress applying when it fetches.
42
+ DeepSeek images require RubyLLM's [image-attachment fix](https://github.com/crmne/ruby_llm/pull/972),
43
+ included in this deployment's pinned revision, and a vision-capable model.
42
44
  - **Documents** (a prescription, a recipe, an invoice — most often a PDF)
43
45
  attach the same way images do; the first document URL is
44
46
  `{{ctx.document_url}}`. A model without document support fails the ask at
@@ -117,8 +119,10 @@ end
117
119
  - **Seams, not magic.** The generator is injectable per kind (specs stub it);
118
120
  the defaults are lazy: images via RubyLLM (paint), speech via a thin POST to
119
121
  the OpenAI-compatible `/audio/speech` endpoint using the same provider
120
- config the chat uses — RubyLLM as of 1.16.0 has no speech API. A generated
121
- part over 8 MB refuses loudly, never silently truncates.
122
+ config the chat uses. RubyLLM 2 has a speech API, but its inspected native
123
+ transport buffers non-audio/error responses without yielding chunks. Insika
124
+ retains its bounded HTTP transport until native limits cover those responses.
125
+ A generated part over 8 MB refuses loudly, never silently truncates.
122
126
  - **Not here:** what the generated image *means* — a fitting room, a product
123
127
  mockup — is a skill on top. The engine transports bytes and cost.
124
128
 
@@ -5,7 +5,44 @@ nav_order: 1
5
5
  permalink: /observability/
6
6
  ---
7
7
 
8
- # Observability — OpenTelemetry (opt-in)
8
+ # Observability — native model diagnostics and OpenTelemetry
9
+
10
+ ## Studio Models
11
+
12
+ Open the operator-authenticated `/studio/models` page to filter native
13
+ requests by the last 24 hours, 7 days, or 30 days, agent, provider, and model. It shows
14
+ request volume, failures, retries, reported USD cost and unknown-cost coverage,
15
+ p50/p90/p95 latency, separate input/output/cache-read/cache-write/thinking token
16
+ subtotals, and the 20 slowest measured requests linked to their tasks.
17
+
18
+ Charts show request volume, reported cost, and p50/p90/p95 latency over time,
19
+ plus a ranked cost comparison by model. They share the page filters. The 24-hour
20
+ view uses hourly intervals, 7 days uses six-hour intervals, and 30 days uses
21
+ daily intervals, all in UTC. Each chart has an expandable table of exact values.
22
+ Missing cost or duration leaves a gap; no-call intervals have zero requests.
23
+ The page is a snapshot: apply the filters again to refresh it.
24
+
25
+ Request latency includes native retries. Retries count usage attempts beyond the
26
+ first within a request, not Insika task executions. Percentiles use completed
27
+ requests with measured durations. Cost and token values sum only reported usage;
28
+ when coverage is incomplete, they are subtotals, and a dash means no value was
29
+ reported. An unknown-cost failed attempt followed by a priced success leaves
30
+ partial cost coverage even when the subtotal is known.
31
+
32
+ Every turn also leaves one row (agent, model, provider, status and its
33
+ windows), whatever `INSIKA_TURN_TIMING` says. A failed turn keeps its row with
34
+ the stage that failed and a total that runs to the failure, so timeouts stay in
35
+ the p95 and the page shows the turn failure rate by stage; that flag only decides what the
36
+ response exposes. **Turn latency** shows p50/p95 of ttft (the provider's first
37
+ token), total, first balloon (what a channel customer waits), queue (debounce
38
+ and FIFO wait before the turn starts), prep and tools. **Tool health** lists
39
+ every tool call in those turns: calls, ok %, p50/p95 ms. A call is a failure
40
+ when its result carries `error`; tools ms sums calls, so parallel calls count
41
+ more than their wall time.
42
+
43
+ The page reads content-free summaries stored independently of the capped task
44
+ trace. History starts when the summaries are deployed; earlier requests are not
45
+ backfilled. Task deletion, retention, and tenant purge remove their summaries.
9
46
 
10
47
  Insika already has an observability spine: the **event stream**. Every turn emits
11
48
  structured events (`task_started`, `tool_call`/`tool_result`, `data_tool_call`,
@@ -25,15 +62,17 @@ authoring writes (`:golden_written`, `:agent_file_written`, …), queue bookkeep
25
62
  channel delivery (`:channel_delivered` — see [Channels](CHANNELS.md))
26
63
  travel the same stream and are **ignored** by the bridge: they open no span and
27
64
  touch no instrument, because they are not part of a turn's latency or cost. Any
28
- other subscriber still sees them. The one mid-turn event the bridge does consume
29
- is `:tool_loop_intervened` — it feeds the `insika.tool.loop_intervened` counter
30
- (no span: the intervention is a fact about the turn, not a timed operation).
65
+ other subscriber still sees them. Additional events feed counters without opening
66
+ spans: `:tool_loop_intervened`, `:tool_blocked` and `:context_compacted`.
31
67
 
32
68
  They are worth subscribing to even so, because each is the ONLY record of
33
69
  something that left no task of its own behind:
34
70
 
35
71
  | Event | Data | What it answers |
36
72
  |---|---|---|
73
+ | `:tool_blocked` | `name`, `gate`, `param` | a provenance gate refused a call before execution; no rejected value in this event |
74
+ | `:ui` | `component`, `title`, `items`, `count`, `dropped` | the presentation selection, including customer-facing card content; see [API](API.md#tool-and-presentation-sse-events) |
75
+ | `:session_seeded` | `session_id`, `tenant`, `keys` | an eval snapshot was loaded; no snapshot contents |
37
76
  | `:turn_coalesced` | `task_id`, `merged`, `arrivals[]` | the fragments a customer typed in a row arrived as separate messages, and when |
38
77
  | `:turn_steered` | `task_id`, `count`, `total` | a message arrived mid-run and was appended to the turn in flight |
39
78
  | `:turn_steer_released` | `task_id`, `released_as`, `count` | the run could not absorb it, so it became the turn `released_as` |
@@ -60,31 +99,52 @@ something that left no task of its own behind:
60
99
  | `:knowledge_deleted` | `name`, `agent` | an operator removed a concept from the Studio |
61
100
  | `:knowledge_retrieved` | `name`, `agent` | the model called `load_knowledge` — the adoption signal (retrieval calls per conversation), not the `<knowledge>` injection itself |
62
101
  | `:knowledge_backfilled` | `agent`, `sessions`, `concepts`, `conflicts`, `dropped{}` | the recovery re-scan (`insika knowledge:backfill`) finished replaying an agent's stored sessions |
102
+ | `:retrieval_reranked` | `provider`, `candidate_count`, `selected_count` | an opt-in memory or knowledge rerank selected candidates; no candidate text |
103
+ | `:provider_warning` | `provider`, `message` | a rerank failed or returned invalid indexes; fallback context remains available |
63
104
 
64
105
  `delivery_failed` and `breaker_open` are the two the operator config is pointed at
65
106
  (`alerts.webhook` on the profile): each only fires when something durable did
66
107
  not land. `:ttft` is additive debug, absent unless `INSIKA_TURN_TIMING` is set.
67
108
 
109
+ Rerank requests have `operation: "rerank"` in native model diagnostics. Their
110
+ reported cost is tracked separately from chat and included once in the total.
111
+ If any rerank request lacks usage, the rerank and total cost remain unknown.
112
+ Reported native rerank tokens, including reported failed attempts, count once
113
+ against agent token ceilings and calendar budgets, separately from chat usage.
114
+ Unknown token fields stay unknown. Diagnostics carry the task and captured turn.
115
+ Candidate and selected counts measure narrowing, not recall: records excluded
116
+ by the lexical index or recent-note window cannot appear in those counts.
117
+
68
118
  `:channel_delivered` is the one worth alerting on: a turn can be `:task_completed`
69
119
  and correct while the customer got nothing, because delivery is a separate,
70
120
  retried, out-of-band step. `status: "failed"` means the reply is sitting in the
71
121
  outbox and the customer is still waiting.
72
122
 
73
- Counts, ids and times only — never message content. The text lives in the
74
- transcript, which is the surface that is allowed to carry it.
123
+ Operational audit events use metadata rather than transcript bodies. Presentation
124
+ `:ui` events intentionally contain customer-facing card content; tool-call events
125
+ can contain arguments. Do not treat the whole event stream as content-free.
75
126
 
76
127
  The bridge speaks the standard the market already runs on: point any OTLP backend
77
128
  at Insika and a real turn shows up as a full trace, next to counters and histograms
78
129
  you can chart without touching a span.
79
130
 
80
- **This page is a convention, not an integration.** Insika ships no dashboard, no
131
+ **The OpenTelemetry section is a convention, not an integration.** Insika ships no OTEL dashboard, no
81
132
  backend config, no vendor file. It ships a stable set of attribute and instrument
82
133
  names, and the recipes below tell you what to chart against them — in whatever you
83
134
  already run.
84
135
 
136
+ ### Blocked tool calls
137
+
138
+ `tool_blocked` carries `name`, `gate`, and `param`, with task/session correlation
139
+ in event metadata. It never carries the rejected value. The `insika.tool.blocked`
140
+ counter (unit `{call}`) uses the turn's agent/tenant/command labels plus
141
+ `insika.tool` and `insika.gate`. Session traces keep the `gate` field alongside the
142
+ masked result; `insika tools:report` lists blocked calls separately from errors.
143
+
85
144
  ## Contents
86
145
 
87
146
  - [Turning it on](#turning-it-on-opt-in-parity-when-off)
147
+ - [Native model diagnostics](#native-model-diagnostics)
88
148
  - [Traces: the span reference](#traces-the-span-reference)
89
149
  - [Metrics: the instrument reference](#metrics-the-instrument-reference)
90
150
  - [Attribute reference](#attribute-reference)
@@ -112,12 +172,43 @@ stay on; `OTEL_METRIC_EXPORT_INTERVAL` (ms) sets the export period. If the metri
112
172
  SDK is not in the bundle at all, the bridge degrades to traces only rather than
113
173
  failing to boot.
114
174
 
115
- **Off (the default):** `Insika::Telemetry.setup` returns `nil`, the OTEL gems are
116
- **never loaded** (lazy `require`, like the LLM client), and nothing is
117
- instrumented — zero overhead. This is enforced by a test
175
+ **Off (the default):** `Insika::Telemetry.setup` returns `nil` and the OTEL gems are
176
+ **never loaded** (lazy `require`, like the LLM client). No OTEL spans or metrics
177
+ are exported; native model diagnostics remain available in Studio. The lazy
178
+ load is enforced by a test
118
179
  (`spec/insika/load_guard_spec.rb`: "require insika does not load
119
180
  OpenTelemetry").
120
181
 
182
+ A failed export (a 404 from an ingest that wants a header, a wrong key) is
183
+ logged to stderr as `ERROR -- otel: OpenTelemetry error: OTLP exporter received
184
+ http.code=…`. `OTEL_EXPORTER_OTLP_HEADERS` is `key=value[,key=value]` and each
185
+ value is URL-decoded: a `+` in a token becomes a space, so write it as `%2B`
186
+ (and `%` as `%25`).
187
+
188
+ ## Native model diagnostics
189
+
190
+ The Studio task page shows `llm_request` and `llm_usage` rows from RubyLLM. A
191
+ request is one logical model operation, including RubyLLM's transport retries;
192
+ each attempt has its own usage row. A successful task can therefore show a failed
193
+ attempt followed by a successful attempt under one request. These are not extra
194
+ task executions. Each request has a locally generated `request_id` that links its
195
+ rows; it is not the provider's HTTP request ID. The rows show operation,
196
+ provider, model, outcome, and five token buckets (input, output, cache read,
197
+ cache write, thinking). Only the request has a measured duration. Attempts have
198
+ no measured duration. Cost is RubyLLM's reported attempt cost, or the
199
+ `INSIKA_MODEL_PRICING` estimate when RubyLLM reports none: unknown
200
+ remains an em dash, while a reported zero remains zero. These diagnostic values
201
+ do not add to turn usage, `insika.tokens`, or `insika.cost`.
202
+
203
+ The diagnostic store retains the latest 200 events per task. Studio marks a
204
+ truncated tail; deleting the task deletes its diagnostics. Insika stores and
205
+ exports only allowlisted scalar fields: event type, timestamp, turn number,
206
+ operation, provider, model, outcome, request ID, duration, exception class,
207
+ token counts, and cost. It excludes prompts, messages, tool arguments, responses,
208
+ URLs, exception messages, and arbitrary RubyLLM payload fields. This restriction
209
+ applies to Insika's diagnostics; an application-supplied RubyLLM instrumenter
210
+ still receives RubyLLM's original payload under the host's own policy.
211
+
121
212
  ## Traces: the span reference
122
213
 
123
214
  One span per **turn**, with **child spans** per tool, correlated by `task_id`.
@@ -128,16 +219,29 @@ Latency is the span duration, reconstructed from the events' real `at` timestamp
128
219
  | `insika.turn` | one per turn, opened on `task_started`, closed on the terminal event | root |
129
220
  | `insika.tool` | one per tool call, `tool_call` → `tool_result` (FIFO-correlated) | `insika.turn` |
130
221
  | `insika.data_tool` | one per data-tool call — point-in-time (the engine emits a single event) | `insika.turn` |
222
+ | `insika.llm.request` | one per retained native request with measured duration, exported when the turn ends | `insika.turn` |
131
223
 
132
224
  A turn that ends with `task_failed` also carries the OTEL **error status**, with
133
- the failure message.
225
+ the exception class (or `Task failed` when the class is unavailable), never the
226
+ exception message. A failed native request uses the same class-only rule.
227
+
228
+ Request spans carry `insika.llm.request_id`, `insika.model`, `insika.provider`,
229
+ `insika.operation`, `insika.status`, `insika.llm.duration_ms`,
230
+ `insika.llm.attempts`, and `insika.llm.failures`. Each retained attempt adds
231
+ `insika.llm.attempt.N.status` and, when known,
232
+ `insika.llm.attempt.N.input_tokens`, `output_tokens`, `cache_read_tokens`,
233
+ `cache_write_tokens`, `thinking_tokens`, and `cost`. The `N` starts at 1. Unknown
234
+ values are omitted. Request spans wait until the turn ends so usage emitted after
235
+ request completion is included. When the 200-event buffer overflows,
236
+ `insika.llm.truncated=true` appears on the turn and request spans: their attempt
237
+ counts describe only the retained tail.
134
238
 
135
239
  ## Metrics: the instrument reference
136
240
 
137
241
  The same events feed instruments, so volume, latency, tokens and cost are chartable
138
242
  **without aggregating spans** — which not every backend does, and none does cheaply
139
- at retention. Metrics are recorded on the *terminal* event, so every point already
140
- knows its outcome.
243
+ at retention. Turn metrics are recorded on the *terminal* event, when its outcome
244
+ is known. Native attempt counters are recorded as usage arrives.
141
245
 
142
246
  | Instrument | Type | Unit | Recorded when |
143
247
  |------------|------|------|----------------|
@@ -145,11 +249,20 @@ knows its outcome.
145
249
  | `insika.turn.duration` | histogram | `s` | same, when both timestamps are known |
146
250
  | `insika.tokens` | counter | `{token}` | the turn reported usage |
147
251
  | `insika.cost` | counter | `{USD}` | the turn's model is priced (see below) |
252
+ | `insika.tool.blocked` | counter | `{call}` | a `tool_blocked` gate refusal |
253
+ | `insika.confirmation` | counter | `{hold}` | a customer confirmation is requested, confirmed, cancelled or expires (`insika.outcome`) |
148
254
  | `insika.tool.calls` | counter | `{call}` | a tool call completes |
149
255
  | `insika.tool.duration` | histogram | `s` | a `tool_call`/`tool_result` pair completes |
150
256
  | `insika.cache.hit_rate` | histogram | `%` | a turn reported billed prompt tokens (see below) |
151
257
  | `insika.tool.loop_intervened` | counter | `{intervention}` | the loop detector delivered its one-shot warning |
152
258
  | `insika.context.compacted` | counter | `{compaction}` | an in-session compaction was persisted (RFC-0044) |
259
+ | `insika.llm.attempts` | counter | `{attempt}` | each native usage event, including retries |
260
+ | `insika.llm.failures` | counter | `{attempt}` | each native usage event with failed status |
261
+
262
+ The native attempt counters count every received attempt, even when the span
263
+ buffer has discarded earlier rows. They use the turn's agent/tenant/command
264
+ labels plus `insika.model`, `insika.provider`, `insika.operation`, and
265
+ `insika.status`; neither task nor request ID becomes a metric label.
153
266
 
154
267
  `insika.tool.duration` is deliberately **not** recorded for data-tools: those are a
155
268
  single point-in-time event, so there is no measured duration to report. A tool left
@@ -189,10 +302,11 @@ metric attribute with per-turn cardinality is how you destroy a metrics backend.
189
302
  | `insika.status` | string | `turn` | turn instruments | `ok` / `error` / `cancelled` / `abandoned` |
190
303
  | `insika.model` | string | `turn` | all except tool | model id the provider reported |
191
304
  | `insika.model_source` | string | `turn` | — | which config layer resolved the model (chat / agent / model / global) |
192
- | `insika.tokens.input` | int | `turn` | — | input tokens (**includes** the cached ones) |
305
+ | `insika.provider` | string | `turn` | — | provider of the turn's final model selection (the fallback's, when one answered) |
306
+ | `insika.tokens.input` | int | `turn` | — | non-cached input tokens |
193
307
  | `insika.tokens.output` | int | `turn` | — | output tokens |
194
- | `insika.tokens.total` | int | `turn` | — | input + output |
195
- | `insika.tokens.cached` | int | `turn` | — | cache **reads**, a subset of `tokens.input` |
308
+ | `insika.tokens.total` | int | `turn` | — | non-cached input + output subtotal; cache buckets are separate |
309
+ | `insika.tokens.cached` | int | `turn` | — | cache **reads**, separate from `tokens.input` |
196
310
  | `insika.tokens.cache_creation` | int | `turn` | — | cache **writes**, *not* inside `tokens.input` |
197
311
  | `insika.token.type` | string | — | `insika.tokens` | `input` / `output` / `cached` / `cache_creation` |
198
312
  | `insika.cost.usd` | double | `turn` | — | estimated cost of the turn (span attribute; the metric is the `insika.cost` counter) |
@@ -219,12 +333,27 @@ attributes are there for it.
219
333
 
220
334
  ## Estimated cost
221
335
 
222
- Insika ships **no prices**. They change weekly, differ per contract and per region,
223
- and a stale table inside the engine would be worse than no number. You declare the
224
- rates; Insika multiplies. Unset, no cost is reported anywhere.
336
+ RubyLLM supplies attempt-time usage and cost. Insika aggregates native chat usage
337
+ across tool rounds and retries, including recorded usage before a failure, and
338
+ exports the recorded `cost_usd`. Unknown costs remain unknown, not zero.
339
+ Insika maintains no competing provider price catalog.
340
+
341
+ Internal token buckets are disjoint. The `total_tokens` field retains its historical
342
+ non-cached input/output subtotal; budgets and evals add the cache buckets once.
343
+ The Responses HTTP adapter projects cache-inclusive `input_tokens` and `total_tokens`.
225
344
 
226
- Set `INSIKA_MODEL_PRICING` to a JSON object of model id → rates in **USD per
227
- million tokens**:
345
+ RubyLLM prices from its model catalog. Insika keeps that catalog in its store
346
+ and refreshes it every `INSIKA_MODEL_REFRESH_HOURS` (default 24; 0 keeps the
347
+ gem's bundled catalog): one worker downloads it, the others reload it, and a
348
+ restart starts from the last download instead of the catalog shipped with the gem.
349
+
350
+ `INSIKA_MODEL_PRICING` optionally fills in cost where RubyLLM reports none (a
351
+ model missing from its registry, a negotiated contract) — everywhere: Studio
352
+ Models, task traces, turn usage and telemetry. RubyLLM's cost always wins, so the
353
+ table never goes stale against a priced model. Rates are a JSON object of model
354
+ id to **USD per million tokens**.
355
+ Each request is priced under its own model, so a turn that ran a failed primary,
356
+ a fallback and the router costs each at its own rate:
228
357
 
229
358
  ```bash
230
359
  INSIKA_MODEL_PRICING='{
@@ -237,13 +366,17 @@ INSIKA_MODEL_PRICING='{
237
366
  `provider/` prefix — `deepseek/deepseek-v4-flash` and `deepseek-v4-flash` both hit the
238
367
  same entry.
239
368
  - `input` / `output` are required (one of them is enough for the entry to load).
240
- - `cached_input`, when given, bills cache **reads** at that rate and subtracts them
241
- from the fresh input. Omit it and cached tokens simply stay at the input rate.
369
+ - `cached_input`, when given, bills cache **reads** separately at that rate.
370
+ Omit it and cache reads use the input rate; they are never subtracted from fresh input.
242
371
  - `cache_write`, when given, bills cache **creation** tokens at that rate. Omit it
243
372
  and they are billed at the input rate.
244
- - An **unpriced model reports nothing** — no attribute, no metric point. A missing
245
- price is not a zero cost, and a dashboard should show the gap.
246
- - A malformed table degrades to "no cost". Telemetry config can never stop a boot.
373
+ - A model RubyLLM prices never reads the table. If neither supplies a cost,
374
+ there is no attribute or metric point. A missing price is not zero.
375
+ - A malformed table disables the override. Telemetry config cannot stop a boot.
376
+
377
+ Routing (`operation: "routing"`) and summarizing compaction
378
+ (`operation: "compaction"`) requests are recorded like the chat's, so they show
379
+ on Studio Models next to it.
247
380
 
248
381
  The number is an **estimate for trend and attribution**, not a bill. Reconcile
249
382
  against your provider's invoice, never the other way round.
@@ -291,7 +424,10 @@ climbing while `input` stays flat.
291
424
  `insika.tokens` filtered to `insika.token.type="cached"` over the same counter
292
425
  filtered to `input`. This is the number that moves your bill. For the per-turn
293
426
  distribution (does every turn hit, or do fleet averages hide cold agents?), chart
294
- `insika.cache.hit_rate` — p50 by `insika.agent`; a healthy agent sits near 100.
427
+ `insika.cache.hit_rate` — p50 by `insika.agent`. Compare with that agent's baseline:
428
+ cache eligibility, expiry and changing history affect the ratio. The
429
+ [identity-prefix fingerprint](CONTEXT.md#the-observable-cache-fingerprints-and-the-invalidation-reason)
430
+ explains identity/tool-schema changes; volatile category digests are separate.
295
431
 
296
432
  **Loop interventions**
297
433
  `insika.tool.loop_intervened`, rate, grouped by `insika.agent` and `insika.tool`.
@@ -374,7 +510,7 @@ Set the OTEL envs on the deployment and every worker exports to your collector:
374
510
  INSIKA_OTEL=1
375
511
  OTEL_EXPORTER_OTLP_ENDPOINT=https://<your-collector>:4318
376
512
  OTEL_SERVICE_NAME=insika # optional; names the service
377
- INSIKA_MODEL_PRICING='{...}' # optional; unlocks the cost attribute + counter
513
+ INSIKA_MODEL_PRICING='{...}' # optional negotiated-rate override for telemetry
378
514
  # plus any standard OTEL_EXPORTER_OTLP_HEADERS your backend needs
379
515
  ```
380
516