insika 0.0.1 → 0.2.0

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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
data/docs/EVALS.md ADDED
@@ -0,0 +1,273 @@
1
+ ---
2
+ title: Evals
3
+ parent: Operate & prove it
4
+ nav_order: 4
5
+ permalink: /evals/
6
+ ---
7
+
8
+ # Evals
9
+
10
+ An agent has no unit test. The same prompt, the same tools and the same model can give
11
+ a different answer twice, so "does it still work?" cannot be answered by asserting on a
12
+ string. What you can do is keep a small set of **conversations you care about**, replay
13
+ them against a running deployment, and check two different kinds of thing:
14
+
15
+ - **deterministic** — did it call `search_voucher`? did the reply leak a CPF? did a
16
+ tool error? No model involved, no flakiness, no token cost.
17
+ - **subjective** — did it actually resolve the doubt, without inventing a discount?
18
+ That one needs a reader, so a model reads it against a **rubric you write**.
19
+
20
+ That is the whole idea. The rest of this page is the format, who grades, and how a run
21
+ becomes a gate.
22
+
23
+ ## A case is data
24
+
25
+ ```yaml
26
+ id: loja-chocolates-cupom
27
+ agent: loja-chocolates
28
+ turns:
29
+ - user: "tem algum cupom de desconto ativo?"
30
+ expect:
31
+ tools_called:
32
+ - search_voucher # a trailing "?" marks it optional
33
+ must_not:
34
+ - pii_leak
35
+ - tool_error
36
+ rubric: |
37
+ Consults the active coupon with the tool and says what it is — does NOT invent a
38
+ code or a percentage. If there is none, says so kindly.
39
+ min_score: 0.7
40
+ ```
41
+
42
+ Turns replay **in order** under one conversation, so a case can build context ("what
43
+ about the shipping?" after "I want the 70% bar"); the assertions run on the last turn.
44
+
45
+ ### `requires` — a case that cannot run here is skipped, not failed
46
+
47
+ Deployments differ. Some stores have order tracking wired, some do not; some run
48
+ promotions, some do not. A case asserting `search_orders` is **not a failure** for a
49
+ store without it — it is a case that should never have run, and a red suite nobody
50
+ trusts is worse than a small one.
51
+
52
+ ```yaml
53
+ requires:
54
+ tools: [search_orders] # must be available to the agent
55
+ capabilities: [promotions] # facts the operator declared about the deployment
56
+ expect:
57
+ tools_called:
58
+ - search_orders
59
+ ```
60
+
61
+ The runner asks the deployment what that agent has (`GET /v1/agents/:id`, gated like
62
+ every `/v1` read) **before** spending a turn, and reports a third outcome beside pass
63
+ and fail: **skipped, with the reason**. The rule of thumb: a case that asserts a tool
64
+ should require it.
65
+
66
+ Capabilities are a flat list the operator writes on the agent — inferring "this store
67
+ has promotions" from data is how a suite starts lying:
68
+
69
+ ```ruby
70
+ declares "promotions", "human_handoff"
71
+ ```
72
+
73
+ Three deliberate edges:
74
+
75
+ - An agent with an **open** tool allowlist runs the case. "I could not rule it out" is
76
+ not a reason to stop testing something.
77
+ - If the deployment cannot be read at all, cases with `requires` **run**, and the CLI
78
+ says so once. A suite must not shrink in silence.
79
+ - The gate never blocks *on* a skip, and `--update-baseline` leaves skipped cases out
80
+ of the baseline entirely. But a case that used to pass here and is now skipped **is**
81
+ reported as a regression: it means the agent lost a tool.
82
+
83
+ ### `policy` — how much the agent should ask before acting
84
+
85
+ One optional key, because this is the thing a rubric cannot carry alone. Whether the
86
+ agent should establish the objective before searching, or act on the first plausible
87
+ reading, is a decision **your store** makes — a universal rule would be wrong for half
88
+ of them. Declare it and two things happen: a check that costs nothing runs, and the
89
+ judge is told the rule instead of guessing it.
90
+
91
+ ```yaml
92
+ expect:
93
+ policy: ask_once
94
+ ```
95
+
96
+ | policy | the check | the judge is told |
97
+ |---|---|---|
98
+ | `ask_once` | no reply asks more than one question | two questions in one message is a failure |
99
+ | `investigate_first` | turn 1 asks something and calls no tool | ask on a vague request, don't search immediately |
100
+ | `act_fast` | turn 1 calls a tool | asking what a search would answer is a failure |
101
+
102
+ Omit it and only the rubric decides. Unlike the other assertions, a policy is checked
103
+ on **every** turn — "one question per reply" is a rule about each reply, and in the
104
+ case that motivated this the violation was on the first one.
105
+
106
+ Question counting is deliberately crude: a run of `?` counts once, and URLs are dropped
107
+ so a tracking link's query string is not read as the agent asking something. It is a
108
+ policy signal, not grammar — and crude was enough to catch an agent breaking a rule
109
+ written in its own prompt, twice, with no model in the loop.
110
+
111
+ ### `reference` — compared against the system you want to replace
112
+
113
+ `min_score: 0.7` says a reply cleared a bar you invented. It says nothing about
114
+ whether the system already answering your customers would have done better. If you are
115
+ replacing something, that is the only question that matters — and you have its
116
+ transcripts.
117
+
118
+ Give a case the incumbent's real conversation for the same opening, and a run with
119
+ `--pairwise` asks one judge one question: **which one served the customer better?**
120
+
121
+ ```yaml
122
+ reference:
123
+ source: "helpdesk chat 34403117" # free text, so a reader can find the original
124
+ messages:
125
+ - role: user
126
+ text: "tem creatina?"
127
+ - role: assistant
128
+ text: "temos sim! qual seu objetivo?"
129
+ - role: assistant
130
+ text: "segue o link do produto"
131
+ origin: operator # a HUMAN typed this one
132
+ ```
133
+
134
+ ```bash
135
+ ruby evals/run.rb --agent loja-chocolates --pairwise
136
+ ```
137
+
138
+ Three outcomes — `better`, `comparable`, `worse` — and two more the panel can produce
139
+ and the report will not hide: `split` when the judges disagree, `unknown` when none of
140
+ them answered readably. **It never changes pass/fail.** "Worse than the incumbent" is
141
+ an answer about a replacement decision, not a regression in your suite, and it stays
142
+ out of the gate.
143
+
144
+ Three rules make the number worth quoting:
145
+
146
+ - **The judge is not told which one is yours.** It sees "A" and "B". Told, it would
147
+ have an opinion about the new system instead of about the conversations.
148
+ - **Every judge is asked twice, with the transcripts swapped.** Preferring whatever was
149
+ printed first is the classic failure of pairwise grading, so a verdict that flips is
150
+ reported as `comparable`, marked `order-dependent`.
151
+ - **A person is not the incumbent's model.** `origin: operator` on any reference
152
+ message labels the whole pair `vs: human-assisted`, and the summary counts those
153
+ separately — comparing a model to a person and calling it a win is a lie in both
154
+ directions. The judge is not told; the reader is, which is where it changes a
155
+ decision.
156
+
157
+ Cost: **two provider calls per judge per case**, which is why it is opt-in and never
158
+ part of the gate.
159
+
160
+ Cases live in two places, and it is the same YAML in both:
161
+
162
+ - **`evals/golden/**`** in the repo — the curated corpus, reviewable in a pull request,
163
+ and the seed for a fresh deployment.
164
+ - **the store** — what a deployment actually runs, editable in **Studio → Evals**
165
+ without a checkout. That matters because the rubric is the part of an eval a domain
166
+ owner can write, and asking them for a git branch means it never gets written.
167
+
168
+ ```bash
169
+ insika evals:import # corpus -> store (a fresh deploy starts here)
170
+ insika evals:import --keep-existing # don't overwrite what was authored in the Studio
171
+ insika evals:export --dir /tmp/cases # store -> YAML, at the paths it came from
172
+ ```
173
+
174
+ Export refuses to overwrite an existing corpus unless you pass `--force`: `YAML.dump`
175
+ drops the comments those files carry, and each one explains what its case is for.
176
+
177
+ A case whose stored YAML no longer validates is **listed as broken** on the Evals page
178
+ rather than skipped in silence — a test suite that quietly shrinks is worse than a red
179
+ one.
180
+
181
+ ## Running
182
+
183
+ ```bash
184
+ ADMIN_TOKEN=… ruby evals/run.rb # cases from the store, else the corpus
185
+ ADMIN_TOKEN=… ruby evals/run.rb --source dir # ignore the store (no database needed)
186
+ ADMIN_TOKEN=… ruby evals/run.rb --agent loja-chocolates --mode both
187
+ ```
188
+
189
+ It is **on-demand, not CI**: it costs tokens, needs a live provider key and needs the
190
+ target agents provisioned. `--mode perf` reuses the same replay to report TTFB and
191
+ total latency over real conversations, so one harness answers both questions.
192
+
193
+ A run writes `evals/reports/<timestamp>.json` and prints a markdown summary.
194
+
195
+ ## Who grades: a panel, not a voice
196
+
197
+ The judge reads (conversation, reply, rubric) and returns a score in `[0,1]` with one
198
+ sentence of reason. An unparseable judge reply scores **0** — a broken grader must
199
+ never look like a pass.
200
+
201
+ Configure the graders in **Studio → Settings → Evals**, one `provider/model` per line:
202
+
203
+ | Key | Meaning |
204
+ |---|---|
205
+ | `judges` | one entry per model. Empty = deterministic assertions only, and rubric'd cases read as `judge_pending` |
206
+ | `aggregate` | `median` (default), `mean`, or `min` — how the panel's scores become the one number the report and the baseline read |
207
+ | `min_agreement` | fraction of judges that must pass **on their own**. `0.5` = a majority, `1.0` = unanimous |
208
+ | `quorum` | samples per judge, on top of the panel |
209
+ | `tolerance` | max score drop before it counts as a regression |
210
+
211
+ Several models is the point. Sampling **one** model three times measures that model's
212
+ variance — at temperature 0 it mostly returns the same answer, including the same blind
213
+ spot. Two *different* models disagreeing about a rubric is the signal worth having, and
214
+ the report keeps each judge's score so a split panel is visible instead of hidden
215
+ inside an average.
216
+
217
+ `--judge-model` still overrides everything for a one-off run.
218
+
219
+ ## The gate
220
+
221
+ A **baseline** (`evals/baseline.json`) is the accepted state of the corpus. A gated run
222
+ blocks only on a **regression** — a case that used to pass and now fails, or a judge
223
+ score that dropped past `tolerance` — so known failures don't wedge the gate while a
224
+ real drop does:
225
+
226
+ ```bash
227
+ ruby evals/run.rb --baseline evals/baseline.json # exits non-zero on a regression
228
+ ruby evals/run.rb --update-baseline # accept the current state
229
+ ```
230
+
231
+ That is what you run before merging a prompt, tool or model change. A case with no
232
+ baseline entry never blocks: it shows as failing in the report, but a brand-new case is
233
+ not a regression.
234
+
235
+ ## Honest limits
236
+
237
+ - **A judge is a model.** It has taste and it has bad days; that is why the
238
+ deterministic layer carries the load and the rubric should be objective ("does not
239
+ invent a code" beats "is friendly").
240
+ - **A green run is not a proof.** It says the cases you wrote still behave. Cases come
241
+ from real conversations — see [Refinement](REFINEMENT.md) for reading production
242
+ traffic back to find the ones worth adding.
243
+ - **The corpus is small on purpose.** Twenty cases covering the hot flows beat two
244
+ hundred nobody curates.
245
+ - **A pairwise verdict is a judgement, not a measurement.** It is one model's opinion
246
+ about two conversations. Read a batch of them by hand before quoting the number in a
247
+ decision — if `better` tracks length or politeness rather than whether the customer
248
+ got served, the comparison is measuring the wrong thing and should be dropped.
249
+ - **A pair the deployment's DATA cannot satisfy is not a loss.** `requires` resolves
250
+ tools and capabilities; it says nothing about catalogue content. If the reference
251
+ conversation found a product that does not exist where you are replaying, no query
252
+ could have returned it, and the pair is unrunnable — exactly like a case needing a
253
+ tool the agent does not have. Exclude it by hand and say so; counting it is the same
254
+ lie `requires` exists to end.
255
+ - **A tool that delivers out of band scores as silence.** When a backend sends the card,
256
+ the button or the support contact straight to the channel, the right behaviour is to
257
+ publish nothing ([`halt_when`](TOOLS.md#halt_when-when-the-answer-is-already-out)) —
258
+ and a comparison that only observes what *you* publish cannot tell that from an agent
259
+ that said nothing. Compare conversations whose tools deliver the same way, or read
260
+ those pairs by hand.
261
+ - **Reuse a conversation and you are judging the wrong transcript.** A replay continues
262
+ whatever the conversation already holds, so a pair replayed into a conversation an
263
+ earlier run touched carries both runs' turns. It shows up as a judge that flips when
264
+ the transcripts are swapped — which is the swap-check earning its keep, but the run
265
+ is spent. Mint fresh conversation ids for every pairwise run.
266
+
267
+ ## Where it lives
268
+
269
+ The harness is `lib/insika/evals/*` — inside the engine, because the refinement gate
270
+ needs to score a candidate agent with the same judge, and a second copy of the judge
271
+ would be the worst possible outcome. It stays a **client** even so: it reaches a running
272
+ deployment over HTTP through `POST /v1/responses` and never reads a store directly.
273
+ `evals/run.rb` is a thin CLI over it.
data/docs/LOADTEST.md ADDED
@@ -0,0 +1,232 @@
1
+ ---
2
+ title: Load test
3
+ parent: Operate & prove it
4
+ nav_order: 3
5
+ permalink: /loadtest/
6
+ ---
7
+
8
+ # LOADTEST — Insika
9
+
10
+ How to load-test the engine, how to compare it **apples-to-apples against another
11
+ gateway**, and how to read the numbers — covering the SQLite-topology question
12
+ (does one box hold up?) and loadtest parity. For deploy/env details see
13
+ [DEPLOY.md](DEPLOY.md).
14
+
15
+ The whole point: the engine exposes `POST /v1/responses` as an **SSE drop-in** of
16
+ the OpenClaw gateway. Same contract → the same load tools work against either side,
17
+ so you can measure the engine you are about to ship against the gateway it replaces.
18
+
19
+ There are three scripts, each answering a different question:
20
+
21
+ | Script | Question it answers | Needs a provider? |
22
+ |--------|---------------------|-------------------|
23
+ | `scripts/bench_store.rb` | Does SQLite (WAL) hold up N processes writing the same file? | No |
24
+ | `scripts/loadtest.rb` | End-to-end: TTFB/total/tokens/cache/error against `/v1/responses` | Yes |
25
+ | `scripts/loadtest-local.sh` | Single-proc baseline vs N-worker multi-proc on one box | Yes |
26
+ | `scripts/loadtest_session.rb` | A full multi-message session (CEP, searches, FAQ) under C concurrent sessions — direct to the engine (`--surface engine`, stream vs steer) or through the consumer's real ingress (`--surface web`, the consumer's widget API) | Yes |
27
+
28
+ All three take `--help` / `-h`.
29
+
30
+ ---
31
+
32
+ ## 1. `bench_store.rb` — SQLite write ceiling (no provider)
33
+
34
+ Isolates "does SQLite survive multi-process?" from LLM-provider noise. N processes
35
+ hammer writes against the **same file** using the engine's real production config
36
+ (WAL + `busy_timeout` + `BEGIN IMMEDIATE` + in-process semaphore). It uses a fresh
37
+ temp db per round, so it never touches your `INSIKA_DB`.
38
+
39
+ ```bash
40
+ bundle exec ruby scripts/bench_store.rb [PROCS_CSV] [WRITES_PER_PROC]
41
+ # defaults: 1,2,4,8 procs, 2000 writes each
42
+ bundle exec ruby scripts/bench_store.rb 1,2,4,8 3000
43
+ ```
44
+
45
+ Output columns: `procs | wall(s) | writes/s | p50(ms) | p95(ms) | max(ms) | locked`.
46
+
47
+ **What to look for:** aggregate `writes/s` stays roughly flat as procs grow (the
48
+ WAL "1 writer at a time" ceiling), and `locked` (i.e. `database is locked`) is **0**
49
+ — the `busy_timeout` turns contention into tail latency (`max`), not errors. A real
50
+ turn is provider-bound (seconds) and does only a handful of writes, so the workload
51
+ sits ~100× below this ceiling. See DEPLOY.md for the measured numbers.
52
+
53
+ ---
54
+
55
+ ## 2. `loadtest.rb` — end-to-end against `/v1/responses` (with provider)
56
+
57
+ Hits `POST /v1/responses` (SSE) directly — the production path
58
+ (a consumer app, e.g. WhatsApp, → engine). Standard library only. Fires `agents × concurrency ×
59
+ iterations` turns in waves of `concurrency`, and per turn records TTFB (time to
60
+ first SSE byte), total time, and the `usage` block (tokens + cache hit) of the last
61
+ frame that carries it.
62
+
63
+ ```bash
64
+ INSIKA_URL=http://localhost:9292 \
65
+ OPENCLAW_GATEWAY_TOKEN=xxx \
66
+ bundle exec ruby scripts/loadtest.rb \
67
+ --agents bia,my-store --concurrency 16 --iterations 3 \
68
+ --message "hi, how are you?"
69
+ ```
70
+
71
+ Runs against a local server **or** a remote one (e.g. Railway) — just point
72
+ `INSIKA_URL` at it.
73
+
74
+ ### Flags
75
+
76
+ | Flag | Default | Meaning |
77
+ |------|---------|---------|
78
+ | `--agents a,b,c` | `bia` | comma-separated agent ids (mapped to `model: openclaw:<agent>`) |
79
+ | `--concurrency N` | `8` | concurrent turns per wave |
80
+ | `--iterations N` | `1` | number of waves per agent |
81
+ | `--message TEXT` | greeting | user message sent every turn |
82
+ | `--timeout SECONDS` | `120` | per-request read timeout |
83
+ | `--ports 9292,9293` | — | round-robin across local processes (see §4) |
84
+ | `--same-user` | off | boolean toggle — reuse the same `user` per agent to measure the **hot-conversation cache** (legacy `--same-user 1` / `--same-user 0` still work) |
85
+ | `--dry-run` | — | print the plan + one sample request (masked token) and exit; **sends no traffic** |
86
+ | `--help` | — | show usage and exit |
87
+
88
+ ### Environment
89
+
90
+ | Env | Default | Meaning |
91
+ |-----|---------|---------|
92
+ | `INSIKA_URL` | `http://localhost:9292` | base URL of the engine |
93
+ | `OPENCLAW_GATEWAY_TOKEN` | falls back to `ADMIN_TOKEN`, then `local-demo` | Bearer for `/v1/responses` |
94
+ | `DEEPSEEK_API_KEY` | — | must be configured **on the server** for real turns (not read by the client) |
95
+
96
+ Use `--dry-run` to sanity-check your flags/URL/token before firing real traffic
97
+ (and to confirm the request body without needing a running server):
98
+
99
+ ```bash
100
+ INSIKA_URL=http://localhost:9292 OPENCLAW_GATEWAY_TOKEN=xxx \
101
+ bundle exec ruby scripts/loadtest.rb --agents bia --concurrency 16 --dry-run
102
+ ```
103
+
104
+ ### `--same-user` and the cache
105
+
106
+ By default each turn uses a distinct `user` (`loadtest-<agent>-<idx>`), so every
107
+ turn is a cold conversation. With `--same-user` all turns for an agent share one
108
+ `user`, exercising the warm-conversation path — watch the **mean cache hit** rise
109
+ and TTFB drop. Run both to bracket cold vs hot behaviour.
110
+
111
+ ---
112
+
113
+ ## 3. `loadtest-local.sh` — baseline vs multi-worker on one box
114
+
115
+ Boots Falcon with `--count 1` (single-process baseline), runs the sweep, then boots
116
+ `--count N` (multi-process) over the **same** SQLite file (WAL), runs the sweep
117
+ again, and counts `database is locked` in each Falcon log. Since the provider is
118
+ identical across both runs, if multi-proc recovers throughput the ceiling was
119
+ CPU/event-loop (not the provider), and a `locked` count of **0** proves the WAL +
120
+ `busy_timeout` config absorbs cross-process write contention.
121
+
122
+ ```bash
123
+ DEEPSEEK_API_KEY=sk-... ./scripts/loadtest-local.sh [WORKERS] [CONCURRENCY]
124
+ # defaults: 4 workers, 16 concurrency
125
+ ./scripts/loadtest-local.sh 4 24
126
+ ```
127
+
128
+ | Env | Default | Meaning |
129
+ |-----|---------|---------|
130
+ | `DEEPSEEK_API_KEY` | — (required) | real turns hit the provider; also auto-sourced from `.env.local` |
131
+ | `OPENCLAW_GATEWAY_TOKEN` | falls back to `ADMIN_TOKEN`, then `local-demo` | Bearer for the sweep |
132
+ | `PORT` | `9299` | bind port for the local Falcon |
133
+ | `AGENT` | `bia` | agent id to load |
134
+
135
+ The final block prints the lock counts; the expected reading is `0` for both:
136
+
137
+ ```
138
+ === 'database is locked' (expected 0 — WAL + busy_timeout) ===
139
+ baseline (1): 0
140
+ multi (4): 0
141
+ ```
142
+
143
+ ---
144
+
145
+ ## 4. Apples-to-apples: Insika vs the OpenClaw gateway
146
+
147
+ Two ways to compare, both valid because the SSE contract is identical.
148
+
149
+ ### 4a. Ruby native (`loadtest.rb`) against both
150
+
151
+ Run the same `loadtest.rb` invocation twice — once with `INSIKA_URL` pointing at
152
+ the engine, once at the gateway (its `/v1/responses` speaks the same protocol).
153
+ Keep `--agents`, `--concurrency`, `--iterations` and `--message` identical, and use
154
+ matching agents on both sides. Compare the printed TTFB/total/cache/error lines.
155
+
156
+ ### 4b. Reuse OpenClaw's `loadtest-gateway.mjs` unmodified
157
+
158
+ `loadtest.rb` is the Ruby port of OpenClaw's `loadtest-gateway.mjs`. You do **not**
159
+ need to change that script to point it at the engine — because the engine is a
160
+ drop-in for the gateway, you only change **where it points**:
161
+
162
+ ```bash
163
+ # In the OpenClaw checkout, run its gateway loadtest against the HARNESS:
164
+ OPENCLAW_GATEWAY_URL=http://localhost:9292 \
165
+ OPENCLAW_GATEWAY_TOKEN=<same bearer the engine accepts> \
166
+ node scripts/loadtest-gateway.mjs --agents bia --concurrency 16 --iterations 3
167
+ ```
168
+
169
+ Then run the exact same command with `OPENCLAW_GATEWAY_URL` pointing at the real
170
+ gateway, and diff the two reports. This is the shadow comparison the pilot needs.
171
+
172
+ **What the operator must have in hand** (this repo does not vendor OpenClaw):
173
+
174
+ - The OpenClaw checkout containing `scripts/loadtest-gateway.mjs` and Node installed.
175
+ - A **bearer token accepted by both** sides. For the engine that is
176
+ `OPENCLAW_GATEWAY_TOKEN` (see DEPLOY.md); point the gateway run at its own token.
177
+ - **The same agent id provisioned on both** sides (e.g. `bia`) so `model:
178
+ openclaw:<agent>` resolves on each. On the engine, provision via
179
+ `scripts/import_pack.rb`.
180
+ - The **same provider** (or an equivalent-latency one) behind each, otherwise you
181
+ are comparing providers, not engines.
182
+ - Both endpoints reachable from where you run the client, warmed up (hit `/up` on
183
+ the engine first), and ideally driven from the same machine to remove network
184
+ skew.
185
+
186
+ Keep every knob identical between the two runs — the only variable should be which
187
+ engine is behind `/v1/responses`.
188
+
189
+ ---
190
+
191
+ ## 5. Reading the metrics
192
+
193
+ - **TTFB** (time to first SSE byte) — how fast the user starts seeing a response.
194
+ Dominated by provider latency + the engine's per-turn setup (context build,
195
+ policy, first model call). This is the number that most shapes perceived latency.
196
+ - **total** — full turn wall time including the whole tool-loop and streamed
197
+ output. `total − TTFB` is roughly the streaming/tool-loop tail.
198
+ - **P50 vs P95** — P50 is the typical turn; **P95 is the tail you actually feel**
199
+ under load. A P50 that stays flat while P95 balloons as concurrency rises means
200
+ you are queueing (CPU/event-loop or writer contention) — that is the signal to
201
+ add workers (§3) or check `bench_store.rb`.
202
+ - **mean tokens** — average `total_tokens`/`output_tokens` per turn; sanity-checks
203
+ that turns did real work and lets you compare cost between runs/engines.
204
+ - **mean cache hit** — average cached prompt tokens; should rise sharply with
205
+ `--same-user 1`. A high hit rate is why warm conversations are cheaper and faster.
206
+ - **error rate** (`turns ok: X/Y (errors: N)`) — non-2xx, timeouts, or connection
207
+ failures. Anything above ~0 under moderate load is a red flag; inspect server
208
+ logs. Note `loadtest.rb` errors are transport/HTTP-level; `database is locked`
209
+ specifically is counted by `loadtest-local.sh` from the Falcon logs, not here.
210
+ - **throughput** (`turns/s`) — completed turns per wall second; the headline
211
+ capacity number for a given concurrency.
212
+
213
+ ---
214
+
215
+ ## 6. Checklist — what to measure before choosing a topology
216
+
217
+ Work top-down and **measure before assuming** — avoid premature topology optimization.
218
+
219
+ | # | Measure | Tool | Decision it informs |
220
+ |---|---------|------|---------------------|
221
+ | 1 | SQLite write ceiling & `locked` count under N procs | `bench_store.rb` | Is SQLite a bottleneck at all on one box? (Expected: no.) |
222
+ | 2 | Single-proc baseline TTFB/total/P95/throughput | `loadtest.rb` (or `loadtest-local.sh` count 1) | The reference point for everything else. |
223
+ | 3 | Multi-proc on one box: does throughput scale, `locked` = 0? | `loadtest-local.sh` | Do more Falcon workers help, and does the shared WAL hold? Sets `WEB_CONCURRENCY`. |
224
+ | 4 | Insika vs gateway, identical knobs | §4 (either method) | Is the engine at parity with the engine it replaces before cut-over? |
225
+ | 5 | Cold vs hot conversation (cache) | `loadtest.rb` with/without `--same-user 1` | Expected steady-state cost/latency once conversations warm up. |
226
+ | 6 | Remote (Railway) vs local | `loadtest.rb` with `INSIKA_URL` remote | Network/deploy overhead of the real environment. |
227
+
228
+ **Reaching for horizontal scale is only justified after 1–3 show the single box is
229
+ the limit.** If it is, the paths are: sharding-by-tenant +
230
+ sticky routing (recommended), LiteFS, or an optional Postgres adapter — plus
231
+ Litestream for backup/DR regardless of topology. Do not skip straight to Postgres:
232
+ the numbers usually show SQLite on one big box is not the bottleneck.