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
@@ -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`,
@@ -62,11 +99,22 @@ something that left no task of its own behind:
62
99
  | `:knowledge_deleted` | `name`, `agent` | an operator removed a concept from the Studio |
63
100
  | `:knowledge_retrieved` | `name`, `agent` | the model called `load_knowledge` — the adoption signal (retrieval calls per conversation), not the `<knowledge>` injection itself |
64
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 |
65
104
 
66
105
  `delivery_failed` and `breaker_open` are the two the operator config is pointed at
67
106
  (`alerts.webhook` on the profile): each only fires when something durable did
68
107
  not land. `:ttft` is additive debug, absent unless `INSIKA_TURN_TIMING` is set.
69
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
+
70
118
  `:channel_delivered` is the one worth alerting on: a turn can be `:task_completed`
71
119
  and correct while the customer got nothing, because delivery is a separate,
72
120
  retried, out-of-band step. `status: "failed"` means the reply is sitting in the
@@ -80,7 +128,7 @@ The bridge speaks the standard the market already runs on: point any OTLP backen
80
128
  at Insika and a real turn shows up as a full trace, next to counters and histograms
81
129
  you can chart without touching a span.
82
130
 
83
- **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
84
132
  backend config, no vendor file. It ships a stable set of attribute and instrument
85
133
  names, and the recipes below tell you what to chart against them — in whatever you
86
134
  already run.
@@ -96,6 +144,7 @@ masked result; `insika tools:report` lists blocked calls separately from errors.
96
144
  ## Contents
97
145
 
98
146
  - [Turning it on](#turning-it-on-opt-in-parity-when-off)
147
+ - [Native model diagnostics](#native-model-diagnostics)
99
148
  - [Traces: the span reference](#traces-the-span-reference)
100
149
  - [Metrics: the instrument reference](#metrics-the-instrument-reference)
101
150
  - [Attribute reference](#attribute-reference)
@@ -123,12 +172,43 @@ stay on; `OTEL_METRIC_EXPORT_INTERVAL` (ms) sets the export period. If the metri
123
172
  SDK is not in the bundle at all, the bridge degrades to traces only rather than
124
173
  failing to boot.
125
174
 
126
- **Off (the default):** `Insika::Telemetry.setup` returns `nil`, the OTEL gems are
127
- **never loaded** (lazy `require`, like the LLM client), and nothing is
128
- 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
129
179
  (`spec/insika/load_guard_spec.rb`: "require insika does not load
130
180
  OpenTelemetry").
131
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
+
132
212
  ## Traces: the span reference
133
213
 
134
214
  One span per **turn**, with **child spans** per tool, correlated by `task_id`.
@@ -139,16 +219,29 @@ Latency is the span duration, reconstructed from the events' real `at` timestamp
139
219
  | `insika.turn` | one per turn, opened on `task_started`, closed on the terminal event | root |
140
220
  | `insika.tool` | one per tool call, `tool_call` → `tool_result` (FIFO-correlated) | `insika.turn` |
141
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` |
142
223
 
143
224
  A turn that ends with `task_failed` also carries the OTEL **error status**, with
144
- 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.
145
238
 
146
239
  ## Metrics: the instrument reference
147
240
 
148
241
  The same events feed instruments, so volume, latency, tokens and cost are chartable
149
242
  **without aggregating spans** — which not every backend does, and none does cheaply
150
- at retention. Metrics are recorded on the *terminal* event, so every point already
151
- 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.
152
245
 
153
246
  | Instrument | Type | Unit | Recorded when |
154
247
  |------------|------|------|----------------|
@@ -157,11 +250,19 @@ knows its outcome.
157
250
  | `insika.tokens` | counter | `{token}` | the turn reported usage |
158
251
  | `insika.cost` | counter | `{USD}` | the turn's model is priced (see below) |
159
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`) |
160
254
  | `insika.tool.calls` | counter | `{call}` | a tool call completes |
161
255
  | `insika.tool.duration` | histogram | `s` | a `tool_call`/`tool_result` pair completes |
162
256
  | `insika.cache.hit_rate` | histogram | `%` | a turn reported billed prompt tokens (see below) |
163
257
  | `insika.tool.loop_intervened` | counter | `{intervention}` | the loop detector delivered its one-shot warning |
164
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.
165
266
 
166
267
  `insika.tool.duration` is deliberately **not** recorded for data-tools: those are a
167
268
  single point-in-time event, so there is no measured duration to report. A tool left
@@ -201,10 +302,11 @@ metric attribute with per-turn cardinality is how you destroy a metrics backend.
201
302
  | `insika.status` | string | `turn` | turn instruments | `ok` / `error` / `cancelled` / `abandoned` |
202
303
  | `insika.model` | string | `turn` | all except tool | model id the provider reported |
203
304
  | `insika.model_source` | string | `turn` | — | which config layer resolved the model (chat / agent / model / global) |
204
- | `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 |
205
307
  | `insika.tokens.output` | int | `turn` | — | output tokens |
206
- | `insika.tokens.total` | int | `turn` | — | input + output |
207
- | `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` |
208
310
  | `insika.tokens.cache_creation` | int | `turn` | — | cache **writes**, *not* inside `tokens.input` |
209
311
  | `insika.token.type` | string | — | `insika.tokens` | `input` / `output` / `cached` / `cache_creation` |
210
312
  | `insika.cost.usd` | double | `turn` | — | estimated cost of the turn (span attribute; the metric is the `insika.cost` counter) |
@@ -231,12 +333,27 @@ attributes are there for it.
231
333
 
232
334
  ## Estimated cost
233
335
 
234
- Insika ships **no prices**. They change weekly, differ per contract and per region,
235
- and a stale table inside the engine would be worse than no number. You declare the
236
- 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.
237
340
 
238
- Set `INSIKA_MODEL_PRICING` to a JSON object of model id → rates in **USD per
239
- million tokens**:
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`.
344
+
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:
240
357
 
241
358
  ```bash
242
359
  INSIKA_MODEL_PRICING='{
@@ -249,13 +366,17 @@ INSIKA_MODEL_PRICING='{
249
366
  `provider/` prefix — `deepseek/deepseek-v4-flash` and `deepseek-v4-flash` both hit the
250
367
  same entry.
251
368
  - `input` / `output` are required (one of them is enough for the entry to load).
252
- - `cached_input`, when given, bills cache **reads** at that rate and subtracts them
253
- 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.
254
371
  - `cache_write`, when given, bills cache **creation** tokens at that rate. Omit it
255
372
  and they are billed at the input rate.
256
- - An **unpriced model reports nothing** — no attribute, no metric point. A missing
257
- price is not a zero cost, and a dashboard should show the gap.
258
- - 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.
259
380
 
260
381
  The number is an **estimate for trend and attribution**, not a bill. Reconcile
261
382
  against your provider's invoice, never the other way round.
@@ -389,7 +510,7 @@ Set the OTEL envs on the deployment and every worker exports to your collector:
389
510
  INSIKA_OTEL=1
390
511
  OTEL_EXPORTER_OTLP_ENDPOINT=https://<your-collector>:4318
391
512
  OTEL_SERVICE_NAME=insika # optional; names the service
392
- INSIKA_MODEL_PRICING='{...}' # optional; unlocks the cost attribute + counter
513
+ INSIKA_MODEL_PRICING='{...}' # optional negotiated-rate override for telemetry
393
514
  # plus any standard OTEL_EXPORTER_OTLP_HEADERS your backend needs
394
515
  ```
395
516
 
@@ -0,0 +1,110 @@
1
+ ---
2
+ title: Performance
3
+ parent: Operate
4
+ nav_order: 8
5
+ permalink: /performance/
6
+ ---
7
+
8
+ # Performance
9
+
10
+ What the engine does to stay cheap per turn, and what was tried and left out. A turn
11
+ mostly waits on the model provider, so the engine's job is to add little CPU, hold
12
+ little memory and never block the other turns in the same process.
13
+
14
+ To measure your own load, see [Deploy › Measuring performance](DEPLOY.md#measuring-performance--load),
15
+ [Load test](LOADTEST.md) and the [Benchmark](BENCHMARK.md).
16
+
17
+ ## Numbers you can reproduce
18
+
19
+ Each one comes from a script in this repository, with no API key and no real data.
20
+
21
+ | What | Number | Run |
22
+ |---|---|---|
23
+ | Engine work per turn, model stubbed | p50 ≈ 0.4 ms, ~1,700–1,900 turns/s per process | `bundle exec ruby scripts/bench.rb` ([Benchmark](BENCHMARK.md)) |
24
+ | SQLite writes, 1 to 8 processes on one file | ~25–30k writes/s in total, zero "database is locked" | `bundle exec ruby scripts/bench_store.rb 1,2,4,8 3000` ([Deploy](DEPLOY.md#1-sqlite-write-ceiling-no-provider--bench_storerb)) |
25
+ | One key lookup, `execute` vs a cached prepared statement | ~2.7 µs vs ~0.9 µs (Ruby 4.0, laptop) | the snippet below |
26
+
27
+ A turn makes a few hundred store calls at most. Saving ~2 µs on each is well under a
28
+ millisecond per turn, which is why statement caching is in the "left out" table.
29
+
30
+ ```ruby
31
+ require "sqlite3"; require "benchmark"
32
+ db = SQLite3::Database.new(":memory:")
33
+ db.execute("CREATE TABLE kv (scope TEXT, key TEXT, value TEXT, PRIMARY KEY (scope, key))")
34
+ 1000.times { |i| db.execute("INSERT INTO kv VALUES (?, ?, ?)", ["s", "k#{i}", "x" * 500]) }
35
+ sql = "SELECT value FROM kv WHERE scope = ? AND key = ?"
36
+ st = db.prepare(sql)
37
+ Benchmark.bm(9) do |b|
38
+ b.report("execute") { 200_000.times { |i| db.get_first_value(sql, ["s", "k#{i % 1000}"]) } }
39
+ b.report("prepared") { 200_000.times { |i| st.reset!; st.bind_params("s", "k#{i % 1000}"); st.step } }
40
+ end
41
+ ```
42
+
43
+ ## What the engine does
44
+
45
+ **No Rails, no ORM.** The server is Falcon with Rack and Roda. The store is a key-value
46
+ table driven by the `sqlite3` gem directly. Nothing loads a framework or builds model
47
+ objects on the hot path.
48
+
49
+ **One fiber per turn.** Each turn runs as an `Async` task. While it waits on the provider,
50
+ a tool or the store, the same process runs other turns. One worker holds many concurrent
51
+ conversations without threads. See [Architecture](ARCHITECTURE.md).
52
+
53
+ **Session actors end when idle.** Turns of the same session run in order through a small
54
+ actor. Its loop ends as soon as its queue drains and it leaves the executor's table, so an
55
+ idle session holds no fiber.
56
+
57
+ **SQLite tuned for many writers.**
58
+
59
+ - WAL mode with `synchronous = NORMAL`.
60
+ - `busy_handler_timeout=` instead of `busy_timeout=`. The C-level handler sleeps while
61
+ holding the GVL, so a worker waiting on another process's write lock would freeze every
62
+ fiber it runs. The Ruby-level handler lets the scheduler keep the other turns going.
63
+ - **Group commit.** Writes that arrive while one is waiting for the lock join the same
64
+ transaction and share one `COMMIT`. Each caller still returns only after its own write is
65
+ committed, so durability and per-fiber order do not change. Fewer commits means less
66
+ fighting over the file's single write lock when several workers share it.
67
+ - Prefix and recent-items reads are one range query, not one query per key.
68
+ - `INSIKA_SLOW_STORE_MS` logs every slow store call, and every slow group-commit batch with
69
+ its time split into wait, statements and commit. A long wait with a short commit means
70
+ another process holds the lock. A long commit means the batch itself is heavy.
71
+
72
+ **Memory goes back to the OS.**
73
+
74
+ - `MALLOC_ARENA_MAX=2` caps glibc's malloc arenas. Under a multi-worker load, the peak
75
+ stays lower with the same latency.
76
+ - `MALLOC_MMAP_THRESHOLD_=131072` pins the mmap threshold. Large buffers stay mmapped and
77
+ return to the OS when freed instead of fragmenting the arenas.
78
+ - `Insika::MemoryTrim` runs on a quiet worker: no turn in flight and enough allocated since
79
+ the last pass. It runs a full GC and then `malloc_trim(0)`. Without it, a worker stays at
80
+ its peak after a burst, because a GC alone gives nothing back to the OS. It needs the
81
+ `fiddle` gem and does nothing where `malloc_trim` does not exist (macOS, musl).
82
+ - Long-lived fibers return `nil` from their blocks, so a finished task's result is not kept
83
+ alive by a stale reference.
84
+
85
+ **YJIT on.** The reference image sets `RUBY_YJIT_ENABLE=1`. It lowers CPU per turn
86
+ (serialization, streaming, context building) for a small memory cost per worker.
87
+
88
+ **Workers are a contract input, not a tuning knob.** More worker processes means more
89
+ memory and more writers on one SQLite file. Start with one. Add workers, and the
90
+ [Router](ROUTER.md) in front of them, only when one process's CPU is the limit. See
91
+ [Deploy › The process model](DEPLOY.md#the-process-model).
92
+
93
+ ## Tried and left out
94
+
95
+ Each of these was measured on a production-like load and did not pay off. Measure again
96
+ before you reopen one.
97
+
98
+ | Idea | Why it is out |
99
+ |---|---|
100
+ | jemalloc or mimalloc instead of glibc | `MALLOC_ARENA_MAX` plus `MemoryTrim` already got most of the gain. Both left a higher memory floor after repeated loads, and `malloc_trim` does nothing under them. |
101
+ | Preforking workers from a parent with the code loaded (copy-on-write) | Saves memory only right after boot. After a load, each worker's floor is its own heap, not duplicated code. |
102
+ | `GC.compact` during the trim | Fewer heap pages, same RSS. |
103
+ | Async HTTP adapter for the provider | Slower end to end than plain `Net::HTTP`, and it breaks Streamable-HTTP MCP outside an Async reactor. |
104
+ | Write-behind for traces, compressing large stored values | No measurable gain over group commit. Old compressed rows are still read. |
105
+ | Postgres store for speed on one box | Every store call becomes a network round trip, and a turn makes many store calls. Use it to share the store across nodes, not for speed. See [Deploy › Postgres store](DEPLOY.md#postgres-store-optional). |
106
+ | Caching prepared SQLite statements | Saves microseconds per call. Too small next to a turn that waits on the provider. |
107
+
108
+ Once group commit is in place, writer contention on one SQLite file under sustained heavy
109
+ load is a matter of architecture: fewer writer processes, a faster volume, or a shared
110
+ store. More application-level tuning does not move it.
data/docs/PLUGINS.md CHANGED
@@ -73,7 +73,7 @@ require "ruby_llm"
73
73
  module WeatherPlugin
74
74
  class GetWeather < RubyLLM::Tool
75
75
  description "Looks up the current weather for a city"
76
- param :city, desc: "City name"
76
+ parameter :city, description: "City name"
77
77
 
78
78
  def execute(city:) = { city: city, temp_c: 24, condition: "sunny" }
79
79
  end
@@ -169,9 +169,9 @@ require "insika/plugin"
169
169
  Insika::Plugin.announce(File.expand_path("../..", __dir__))
170
170
  ```
171
171
 
172
- Discovery details worth knowing: the manifest may be named `insika.plugin.yml`
173
- (preferred) or `plugin.yml` (deprecated — it warns); one manifest per directory,
174
- with `insika.plugin.yml` winning if both exist; and if two roots ship the same
172
+ Discovery details worth knowing: the manifest is named `insika.plugin.yml` (a leftover
173
+ `plugin.yml` from before 1.0 is not loaded and warns to rename it); one manifest per
174
+ directory; and if two roots ship the same
175
175
  `id`, the **first root wins** and the second is skipped.
176
176
 
177
177
  ### Secrets
data/docs/POLICY.md CHANGED
@@ -39,6 +39,11 @@ ID is blocked without asking the operator. See
39
39
  [Tools](TOOLS.md#provenance-checking-ids-before-a-write) and
40
40
  [Security](SECURITY.md#human-approval).
41
41
 
42
+ Checkpoints preserve tools enabled by `tool_search`; resuming restores only those
43
+ still allowed by the current policy. If a tool halts the turn, unanswered sibling
44
+ calls receive a non-execution result instead of waiting for approval or executing.
45
+ Their outstanding operator approvals are rejected with `resolved_by: engine:halted`.
46
+
42
47
  ## Layer 3: Guardrails (content safety)
43
48
 
44
49
  `guardrails` configures input/output content safety per agent — **opt-in**, so an
@@ -95,8 +100,8 @@ budget daily: 100_000, monthly: 2_000_000, soft: false # or soft: true
95
100
 
96
101
  ### Reliability — retries, fallback, circuit breaker
97
102
 
98
- The provider interaction is a single attempt by default (RubyLLM's own 2
99
- transport retries aside). For a store that cannot have a dead model take the
103
+ Without a reliability policy, transport retries use the provider context's
104
+ configuration. For a store that cannot have a dead model take the
100
105
  chat down, the reliability policy is DATA on the profile:
101
106
 
102
107
  ```ruby
@@ -105,11 +110,12 @@ reliability retries: 2, backoff: "exponential",
105
110
  circuit_breaker: { after: 10, within: 60, cooldown: 300 }
106
111
  ```
107
112
 
108
- - **Retries** — transient failures (`:retryable` / `:rate_limited_*` per the
109
- error classification) retry with exponential backoff, up to `retries`.
110
- A `:fatal` (auth, billing, bad request) is NEVER retried or rotated. Each
111
- attempt runs on a fresh chat — the customer-visible answer comes only from
112
- the attempt that returns.
113
+ - **Retries** — RubyLLM owns transport retries for native chats, up to `retries`
114
+ per request, using its transient-error classification and backoff. Once stream
115
+ data has arrived, the same request is not retried. `backoff: "exponential"`
116
+ doubles the native interval; `"constant"` keeps it fixed. Interval and jitter
117
+ come from the provider context. Insika does not add another retry loop.
118
+ Fatal authentication/billing/request errors do not trigger model rotation.
113
119
  - **Fallback** — after a node's retries, the turn ROTATES to the next model in
114
120
  the chain: the profile's `fallback` refs first, then the platform
115
121
  `fallback_models`. The turn's usage is attributed to the model that actually
@@ -118,12 +124,15 @@ reliability retries: 2, backoff: "exponential",
118
124
  `within` seconds open the circuit; while open, the turn fail-fasts with the
119
125
  typed `circuit_open` + `retry_after` (remaining cooldown) and the provider is
120
126
  never touched. After `cooldown` a half-open trial closes the circuit on
121
- success or reopens it on failure.
122
- - **`timeout`** — per-attempt ceiling (default 30s), counted as a retryable
123
- failure.
124
-
125
- Absent `reliability` = the plain single attempt, byte-for-byte today's
126
- behavior.
127
+ success or reopens it on failure. A native retry sequence that ultimately
128
+ fails counts once, not once per internal HTTP attempt. `provider_failure`
129
+ reports that exhausted sequence; native usage records retain individual attempts.
130
+ - **`timeout`** — the native request timeout and the overall ceiling for one
131
+ model's execution (default 30s), including its retries. Exhaustion can rotate
132
+ to the next allowed model but never adds another same-model retry.
133
+
134
+ Absent `reliability` leaves the provider context unchanged and does not enable
135
+ Insika's model rotation or circuit breaker.
127
136
 
128
137
  ### Intent routing — classify before you answer
129
138
 
data/docs/RELEASING.md CHANGED
@@ -14,6 +14,11 @@ is invisible to it. Do not publish on rspec alone.
14
14
 
15
15
  ## Before anything
16
16
 
17
+ RubyLLM comes from RubyGems (`~> 2.1` in the gemspec); the development Gemfile must
18
+ not Git-pin it, or the published gem resolves a different RubyLLM than the suite ran.
19
+ The measured performance tradeoff was explicitly accepted for the RubyLLM 2 migration,
20
+ not for future regressions; see [migration results](RUBYLLM_2_MIGRATION.md).
21
+
17
22
  1. The suite is green: `bundle exec rspec`.
18
23
  2. `lib/insika/version.rb` carries the version being published.
19
24
  3. Every new `lib/` file is **tracked in git**. The gemspec's `files` come from
data/docs/ROUTER.md CHANGED
@@ -106,6 +106,13 @@ INSIKA_ROUTER_BACKENDS=http://127.0.0.1:9292,http://127.0.0.1:9293,http://127.0.
106
106
  bundle exec insika-router
107
107
  ```
108
108
 
109
+ The image does this for you: set `INSIKA_WORKERS=N` (and keep
110
+ `WEB_CONCURRENCY=1`) and `deploy/entrypoint.sh` runs `deploy/workers.sh`, which
111
+ starts the N workers on ports from `INSIKA_WORKER_BASE_PORT` (default 9300),
112
+ waits until each accepts connections, then binds the router on `$PORT` with a
113
+ backend timeout of `INSIKA_ROUTER_BACKEND_TIMEOUT` (default 1300s there, so a
114
+ long turn is never cut). Unset or `1` boots exactly as before.
115
+
109
116
  `insika doctor` treats `WEB_CONCURRENCY>1` as `ok` (not `error`/`warn`) once
110
117
  it sees `INSIKA_ROUTER_BACKENDS` or `INSIKA_ROUTER_BACKENDS_DNS` set — it
111
118
  cannot verify a router process is actually running at those addresses, only