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
data/docs/TOOLS.md CHANGED
@@ -20,8 +20,8 @@ kinds, and the distinction that matters is **who can change one at runtime**:
20
20
  **MCP tools are not data tools.** Configuring an enabled MCP **instance** (any
21
21
  surface below) is enough — its tools appear automatically, tagged
22
22
  `mcp:<instance>`, and each CALL goes straight to the server through a live,
23
- held client (stdio process / Streamable HTTP / SSE, with the full protocol
24
- handshake) — never a frozen snapshot. See [MCP servers](#mcp-servers) below.
23
+ held native RubyLLM client (stdio / Streamable HTTP, with discovery and legacy
24
+ handshake fallback) — never a frozen snapshot. See [MCP servers](#mcp-servers) below.
25
25
 
26
26
  Code tools **win name collisions** — you cannot register a data tool whose name
27
27
  shadows a code tool.
@@ -55,6 +55,9 @@ write path (`POST /v1/tools/manifest`) — writing this same shape via the DSL
55
55
  or Studio needs the literal header value instead; see
56
56
  "[The one gotcha](#the-one-gotcha-envsecret-templating-is-manifest-only)" below.
57
57
 
58
+ `{{ctx.chat_id}}` is the external session ID. In multi-tenant mode, it excludes
59
+ the authenticated tenant prefix used by Insika to isolate stored sessions.
60
+
58
61
  ### Parameters: the schema is the contract
59
62
 
60
63
  `parameters` is **JSON Schema**, and it reaches the provider verbatim — it is the only
@@ -281,6 +284,41 @@ backend rules such as quantity limits remain the backend's responsibility.
281
284
  The per-tool timeout starts after both gates are acquired. Trace duration includes
282
285
  queueing time, so it measures how long the model waited, not just backend execution.
283
286
 
287
+ ### Customer confirmation: a write the conversation approves
288
+
289
+ Some writes should not happen on the strength of one ambiguous line — closing an
290
+ order, deleting a record, sending a message on someone's behalf. Name them on the
291
+ agent and the engine holds the call for the customer's word:
292
+
293
+ ```ruby
294
+ customer_confirm "create_order" # DSL; the Studio field is "customer confirm"
295
+ ```
296
+
297
+ When the model calls a held tool, nothing runs. The engine records a pending action
298
+ for the session and returns a `pending_confirmation` result carrying the arguments
299
+ and an instruction: tell the customer exactly what will happen and ask. The turn ends
300
+ with that question. On the next message the open hold is rendered at the tail of the
301
+ context, and two system tools are available: `confirm_pending` runs the original call
302
+ with the arguments recorded at the hold (no path exists to confirm different ones),
303
+ `cancel_pending` drops it. A hold that the next message neither confirms nor cancels
304
+ expires on its own — a customer who changed the subject did not agree.
305
+
306
+ Three controls now sit on a write, and they compose in this order:
307
+
308
+ | Control | Who decides | What happens to the turn | Declared on |
309
+ | --- | --- | --- | --- |
310
+ | Provenance (`requires_evidence`) | the ledger | the call is refused, the model is told how to resolve the id | the tool |
311
+ | Customer confirmation (`customer_confirm`) | the customer, next message | the turn ends with a question | the agent |
312
+ | Operator approval (`approvals_required`) | a person in the Studio | the turn suspends and waits | the agent |
313
+
314
+ A tool is confirmed by the customer or approved by the operator, never both. The
315
+ confirmed run still passes provenance, fencing, the side-effect record and the trace;
316
+ the trace shows the hold as `gate: confirmation`, and the `:tool_result` event and the
317
+ Responses stream report it as `status: held`, which the evals treat as neither a
318
+ success nor an error. Whether the customer's "sim" means yes is the model's reading;
319
+ what the engine guarantees is that the write runs only after an explicit question, a
320
+ customer turn, and an explicit tool call.
321
+
284
322
  ### Grounding: policing claims against the ledger
285
323
 
286
324
  With the ledger fed, the pack declares how claims are policed — data on the agent,
@@ -367,7 +405,7 @@ such as suggestion chips (same mechanism, when a channel asks for it).
367
405
  A tool appears in the Studio panel and enters an agent's tool-loop when it is
368
406
  **registered** in the catalog **and** allowed by the agent's policy allowlist.
369
407
  Three ways to write a data tool into the store — all **hot** (registry and catalog
370
- reload, no restart):
408
+ reload, no restart; with several workers, the others pick the change up within ~2 s):
371
409
 
372
410
  1. **DSL** — `data_tool(name:, …)` in a `Insika.agent { … }` block.
373
411
  2. **Studio** — the Tools panel editor.
@@ -390,6 +428,21 @@ any other way must ship literal values** — a real URL, and a real (masked on
390
428
  read) header value. `{{ctx.*}}` and `{{param}}` work everywhere (they resolve
391
429
  at turn time, not ingestion).
392
430
 
431
+ The Studio **Tools** screen groups permissions by origin: native tools, HTTP
432
+ tools, and MCP tools by server instance. Selection remains per tool; grouping
433
+ does not change tool names or grant permissions.
434
+ Each section can be collapsed. Filtering by tool or server name opens matching
435
+ sections without changing selections; clearing the filter restores their state.
436
+ Section selection buttons affect every tool in that section, including filtered
437
+ tools, but never override denied tools. Save tools applies the changes.
438
+
439
+ An MCP instance is a connection, not a single tool. Enable its individual tools
440
+ for the agent in Studio. **Test connection** validates discovery with that
441
+ instance's credentials; it does not validate a separate HTTP data tool or every
442
+ remote tool's execution permissions. When replacing an HTTP tool with MCP,
443
+ remove the old tool from the agent's permissions and update prompt references
444
+ to the actual MCP tool names. Keep the egress allowlist restricted to required hosts.
445
+
393
446
  ## MCP servers
394
447
 
395
448
  An MCP **instance** is durable config — transport, target, credentials, an
@@ -399,19 +452,43 @@ instance is enabled, its tools appear in the catalog automatically (group
399
452
  server through a live, held client — the runtime never converts an MCP tool
400
453
  into a stored data tool.
401
454
 
402
- **Three transports**, picked by `transport:`:
455
+ Each tool is named after its instance: `<instance>__<tool>`, with the instance
456
+ lowercased and anything outside `a-z0-9` turned into `_` (instance
457
+ `shop-staging`, tool `list_products` → `shop_staging__list_products`). That is
458
+ the name the model calls and the one an agent's allowlist holds, so two
459
+ instances that offer the same tool (a store's production and staging) stay two
460
+ tools, each reaching its own server. Per-tool overrides (`tools:`) still use the
461
+ server's own tool name. A qualified name over 64 characters is skipped with a
462
+ warning, since providers reject it: shorten the instance name.
463
+
464
+ The deployment's legacy `import_mcp_tools` command now aliases
465
+ `refresh_mcp_tools`: it returns `{ instance:, tools: }` and emits
466
+ `mcp_tools_refreshed`, replacing the old created/updated import report.
467
+ Previously imported data tools remain unchanged and take precedence over live
468
+ tools with the same name. After refreshing and verifying the live instance,
469
+ delete those snapshots through the normal data-tool controls to use live calls.
470
+ `insika doctor` identifies them by their `mcp:<instance>` group.
471
+
472
+ **Two supported transports**, picked by `transport:`:
403
473
 
404
474
  | Transport | Target | Notes |
405
475
  |---|---|---|
406
476
  | `stdio` | `command` + `args`, run as a child process, `env` is its process environment | requires `INSIKA_MCP_STDIO=1` — see below |
407
- | `http` | `url` + `headers` (Streamable HTTP, the modern default) | egress-guarded like any outbound URL |
408
- | `sse` | `url` + `headers` | same egress guard as `http` |
477
+ | `http` | `url` + `headers` (Streamable HTTP) | egress-guarded; HTTPS required outside loopback |
478
+
479
+ Legacy `sse` records remain stored, but cannot connect through the native client.
480
+ Change the record to `http` with the server's Streamable HTTP endpoint. Event-stream
481
+ responses over Streamable HTTP remain supported; only the old SSE transport is removed.
409
482
 
410
483
  **The stdio gate.** A stdio instance is arbitrary command execution by
411
484
  config — it saves, but refuses to start ("stdio disabled by env") until the
412
485
  operator sets `INSIKA_MCP_STDIO=1` (config-over-convention, the same pattern
413
- as the egress envs). `http`/`sse` need no such gate; their URL is checked by
414
- the normal egress allowlist instead.
486
+ as the egress envs). `http` needs no such gate; its URL goes through the
487
+ egress guard instead: HTTPS, and a host that resolves to a private address
488
+ needs `INSIKA_EGRESS_ALLOW_PRIVATE` and a place in `INSIKA_EGRESS_HOSTS`. A
489
+ public host is not pinned by that list, since the operator fixed the URL and
490
+ the model cannot change it. Native MCP additionally refuses plain HTTP
491
+ outside loopback, even when `INSIKA_EGRESS_ALLOW_HTTP` is enabled.
415
492
 
416
493
  **Credentials are never visible in plaintext.** `env` (stdio) and `headers`
417
494
  (http/sse) mask every value as `__OCULTO__` on read, everywhere (CLI, API,
@@ -443,6 +520,31 @@ held client, which does its own discovery on first use regardless of whether
443
520
  `enabled` flag and its credentials are the **operator's** — a Studio/CLI/API
444
521
  edit made after boot is never clobbered back by the next restart.
445
522
 
523
+ The **first** boot after a declaration also lists the server's tools, because
524
+ until they are listed the agent is offered none of them and answers out of its
525
+ own head instead. A server that cannot be listed (stdio behind a closed gate, a
526
+ host that is down) does not stop the boot — it is warned about by name, and the
527
+ agent runs without those tools until `insika mcp refresh <name>` succeeds.
528
+
529
+ **What the deployment trusts each tool with** — a server describes what its tools
530
+ *do*; only the deployment can say which of its answers are evidence and which
531
+ parameter may only ever carry an id something already returned:
532
+
533
+ ```ruby
534
+ mcp "store", transport: :http, url: ENV.fetch("STORE_MCP"),
535
+ tools: {
536
+ # the store's own field names: no server renames them for us
537
+ "search_products" => { evidence: { kind: "products", items: "products",
538
+ id: "product_id", line: "line" } },
539
+ # a write that may only take an id the customer was actually shown
540
+ "add_to_cart" => { requires_evidence: ["product_id"] }
541
+ }
542
+ ```
543
+
544
+ Both are refused at ingestion, never at the turn: an `evidence:` typo that
545
+ survived would extract nothing and the gate below it would block every write.
546
+ A tool nobody names here behaves exactly as it did before.
547
+
446
548
  2. **CLI** — `insika mcp list | add | remove | import <file.json> | test <name> |
447
549
  refresh <name>`. `add` takes `--name`, `--transport`, `--command`/`--arg`
448
550
  (repeatable) or `--url`/`--header "Name: value"` (repeatable)/`--env
@@ -464,7 +566,7 @@ held client, which does its own discovery on first use regardless of whether
464
566
 
465
567
  `insika mcp import FILE.json` upserts every entry (a bare `command` implies
466
568
  `stdio`; a bare `url` implies `http`; add `"transport": "sse"` explicitly
467
- for SSE — the bare format has no other way to spell it). The same parser
569
+ only to retain a legacy SSE record pending migration). The same parser
468
570
  backs `PUT /v1/mcp` and Studio's "Import JSON" box; `export` produces the
469
571
  document back with secrets masked as `__OCULTO__`, so round-tripping an
470
572
  export never wipes a stored credential.
data/docs/operate.md CHANGED
@@ -10,10 +10,13 @@ permalink: /operate/
10
10
  Running it once it is live: seeing what happened, firing turns nobody sent,
11
11
  handing back a report, and knowing what the box will take before it takes it.
12
12
 
13
+ - **[Studio](STUDIO.md)** — the operator UI: live conversations, why each reply, the review inbox, what a change did.
13
14
  - **[Observability](OBSERVABILITY.md)** — the event stream, and turns as OpenTelemetry traces and metrics.
14
15
  - **[Schedules](SCHEDULING.md)** — recurring turns the engine fires on its own tick.
15
16
  - **[Artifacts](ARTIFACTS.md)** — a report the agent can hand you a URL to.
16
17
  - **[Load test](LOADTEST.md)** — end-to-end load testing, and comparing topologies before you choose one.
17
18
  - **[Soak](SOAK.md)** — the long run that finds what a short one cannot.
18
19
  - **[Benchmark](BENCHMARK.md)** — the engine's measured overhead, reproducible without an API key.
20
+ - **[Cross-harness bench](CROSS-HARNESS-BENCH.md)** — the same store answered by six harnesses, graded on the store's own state.
21
+ - **[Performance](PERFORMANCE.md)** — what keeps a turn cheap, and what was tried and left out.
19
22
  {: .card-grid }
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Platform defaults for agent fields (Settings["agent_defaults"]): an agent
5
+ # with no value of its own for an INHERITABLE field runs with the platform's.
6
+ # The agent's own value always wins, whole (no merge of the two).
7
+ #
8
+ # Applied only where turns read profiles (the graph, through ProfileSource
9
+ # below). Authoring (Studio, pack import) reads the agent's own record, so
10
+ # saving an agent never copies a default into it and it keeps inheriting.
11
+ #
12
+ # FIELDS are the ones where "absent" already means "not configured". Fields
13
+ # where absent means something else (tools_allow nil = every tool) or where
14
+ # absent and off read the same (the memory/fencing booleans) are left out.
15
+ module AgentDefaults
16
+ FIELDS = %w[reliability knowledge memory_retrieval budget alerts guardrails
17
+ grounding followup distill harvest].freeze
18
+
19
+ module_function
20
+
21
+ # -> the profile, rebuilt with the defaults it lacks (the same object when
22
+ # there is nothing to fill).
23
+ def apply(profile, defaults)
24
+ fill = FIELDS.each_with_object({}) do |field, acc|
25
+ value = defaults[field]
26
+ acc[field.to_sym] = value if !value.nil? && profile.public_send(field).nil?
27
+ end
28
+ return profile if fill.empty?
29
+
30
+ AgentProfile.build(**profile.to_h.merge(fill))
31
+ end
32
+
33
+ # Like #apply, but a default the agent cannot be built with (saved before it
34
+ # was validated, or a rerank model the registry dropped since) leaves the
35
+ # agent on its own values: one bad default must not stop every turn.
36
+ def apply_safely(profile, defaults)
37
+ apply(profile, defaults)
38
+ rescue Insika::ValidationError => e
39
+ warn "[insika] agent defaults not applied to #{profile.id}: #{e.message}"
40
+ profile
41
+ end
42
+
43
+ # Raises ValidationError unless an agent can be built with these defaults.
44
+ def validate!(defaults)
45
+ fields = defaults.to_h { |field, value| [field.to_sym, value] }
46
+ AgentProfile.build(id: "agent-defaults", model: nil, **fields)
47
+ defaults
48
+ end
49
+
50
+ # The source as turns read it (defaults + kits applied), for turn-time readers
51
+ # wired outside the graph (workflow triggers, persona evals). No settings store
52
+ # = nothing to apply, the source as is.
53
+ def for_turns(source, settings_store)
54
+ return source if settings_store.nil?
55
+
56
+ ProfileSource.new(Insika::ProfileSource.coerce(source), settings_store: settings_store)
57
+ end
58
+
59
+ # The agent's own records behind a profile source: what authoring reads and
60
+ # writes. A plain source is its own.
61
+ def own(source) = source.is_a?(ProfileSource) ? source.source : source
62
+
63
+ # A ProfileSource that hands out profiles with the platform defaults and the
64
+ # agent's kits (Insika::Kits) applied.
65
+ # Read-only: writing a profile read here would save the defaults into the
66
+ # agent, so put/delete refuse; writers use #source (AgentDefaults.own).
67
+ # Other reads (all_raw) go to the wrapped source untouched.
68
+ class ProfileSource
69
+ include Insika::ProfileSource
70
+
71
+ attr_reader :source
72
+
73
+ def initialize(source, settings_store:)
74
+ @source = source
75
+ @settings_store = settings_store
76
+ end
77
+
78
+ def fetch(id)
79
+ profile = @source.fetch(id)
80
+ profile && Insika::Kits.apply(fill(profile, normalized), kits)
81
+ end
82
+
83
+ def all
84
+ values = normalized
85
+ by_name = kits
86
+ @source.all.map { |profile| Insika::Kits.apply(fill(profile, values), by_name) }
87
+ end
88
+
89
+ def ids = @source.ids
90
+
91
+ def put(*) = raise(Insika::Error, "agent profiles are written through their own source (AgentDefaults.own)")
92
+ def delete(*) = put
93
+
94
+ def respond_to_missing?(name, include_private = false) = @source.respond_to?(name, include_private) || super
95
+
96
+ def method_missing(name, ...)
97
+ return super unless @source.respond_to?(name)
98
+
99
+ @source.public_send(name, ...)
100
+ end
101
+
102
+ private
103
+
104
+ # Settings["kits"] — applied after the defaults (Insika::Kits).
105
+ def kits = @settings_store.get["kits"] || {}
106
+
107
+ # Every inheritable field is normalized on its own by AgentProfile.build,
108
+ # so a profile takes the normalized values as they are (Data#with) and is
109
+ # not rebuilt per read.
110
+ def fill(profile, values)
111
+ missing = values.select { |field, _| profile.public_send(field).nil? }
112
+ missing.empty? ? profile : profile.with(**missing)
113
+ end
114
+
115
+ # The defaults as AgentProfile.build normalizes them, rebuilt only when the
116
+ # stored defaults change. Frozen: every profile shares them. A field that
117
+ # does not build is left out (warned once per change), so one bad default
118
+ # cannot stop every turn.
119
+ def normalized
120
+ raw = @settings_store.get["agent_defaults"] || {}
121
+ digest = raw.hash
122
+ return @normalized if digest == @normalized_digest
123
+
124
+ @normalized = build_values(raw.slice(*FIELDS).compact)
125
+ @normalized_digest = digest
126
+ @normalized
127
+ end
128
+
129
+ def build_values(raw)
130
+ raw.filter_map do |field, value|
131
+ built = AgentProfile.build(id: "agent-defaults", model: nil, field.to_sym => value)
132
+ [field.to_sym, Ractor.make_shareable(built.public_send(field))]
133
+ rescue Insika::ValidationError => e
134
+ warn "[insika] agent defaults: #{field} not applied: #{e.message}"
135
+ nil
136
+ end.to_h
137
+ end
138
+ end
139
+ end
140
+ end
@@ -31,6 +31,13 @@ module Insika
31
31
  entry(agent_id, filename.to_s)&.fetch("content", nil)
32
32
  end
33
33
 
34
+ # -> { name => content } every file of the agent, from ONE read of its record
35
+ # (which also carries the version history). Callers that need several files
36
+ # use this instead of `read` per file.
37
+ def contents(agent_id)
38
+ files(agent_id).transform_values { |entry| entry["content"] }
39
+ end
40
+
34
41
  # -> [String] the agent's file names, lexicographic order.
35
42
  def list(agent_id)
36
43
  files(agent_id).keys.sort
@@ -88,6 +95,11 @@ module Insika
88
95
  entry(agent_id, filename.to_s)&.fetch("history", []) || []
89
96
  end
90
97
 
98
+ # -> String | nil: when the current content was written (iso8601).
99
+ def updated_at(agent_id, filename)
100
+ entry(agent_id, filename.to_s)&.fetch("updated_at", nil)
101
+ end
102
+
91
103
  # Restores version `index` from history as the current content (a new write:
92
104
  # the current one goes to the top of history). -> Hash (entry) | raises if index
93
105
  # invalid / file nonexistent.
@@ -35,12 +35,17 @@ module Insika
35
35
  # a per-skill flag forced one decision onto every
36
36
  # allowlist holding the skill. NOT `Allowlist`
37
37
  # semantics — nil means NONE here (SkillCatalog#eager_for).
38
+ :kits, # kit names (Insika::Kits): bundles of skills/tools applied
39
+ # at READ time by AgentDefaults::ProfileSource — never
40
+ # copied into the record. Always a [String] ([] = none).
38
41
  :context_providers, # provider allowlist
39
42
  :workflows_allow, # applied by WorkflowAllowlist
40
43
  :policies, # names in the Policy Registry
41
44
  :prompt_refs, # names in the Prompt Catalog
42
45
  :limits, # timeouts/budgets
43
46
  :approvals_required, # tools that require approval (ApprovalRequired)
47
+ :customer_confirm, # tools the CUSTOMER confirms on the next message: the
48
+ # call is held, the turn ends with a question (nil = none)
44
49
  :capabilities, # intents the agent can trigger.
45
50
  # nil = NONE (opt-in, see above).
46
51
  :subagents, # allowlist of child agent ids this agent MAY spawn
@@ -51,9 +56,11 @@ module Insika
51
56
  :tools_deferred, # searchable-not-wired tools (Tool Search).
52
57
  # nil = no deferred (all eager — parity);
53
58
  # [names] ⊆ allowed_tools, exposed via tool_search.
59
+ :shared_conversations, # opt-in authoritative conversation persistence
54
60
  :memory, # cross-session memory.
55
61
  # nil/false = OFF (parity: provider []; the `remember`
56
62
  # tool not wired); true = ON. Same opt-in as capabilities.
63
+ :memory_retrieval, # optional reranked context selection; nil = legacy memory block.
57
64
  :prompt_caching, # Anthropic prompt caching (R3): nil/false = OFF
58
65
  # (parity); true = ON. Same opt-in as `memory`. When ON
59
66
  # AND the resolved provider is Anthropic, ChatBuilder sets
@@ -95,7 +102,10 @@ module Insika
95
102
  # release: the goldens were baselined on unfenced bytes.
96
103
  :params, # LLM generation params: a Hash with
97
104
  # temperature/max_tokens/thinking, applied to the chat at
98
- # stage 5. {} = provider defaults (parity).
105
+ # stage 5. {} = provider defaults (parity). Plus
106
+ # `provider_routing` — which UPSTREAM serves the model, for
107
+ # a gateway that has several; emitted under the wire key
108
+ # `provider` (OpenRouter's shape).
99
109
  :budget, # spend caps per (tenant, agent) over
100
110
  # CALENDAR windows (WS2): { "daily" => int,
101
111
  # "monthly" => int, "soft" => bool, "alert_at" => 0.8 }.
@@ -330,9 +340,10 @@ module Insika
330
340
  # platform `default_model` (Settings) at turn start via the ModelResolver.
331
341
  def self.build(id:, model: nil, provider: nil, base_prompt: "", prompt_files: [],
332
342
  tools_allow: nil, tools_deny: [], tools_allow_groups: nil, skills: nil,
333
- skills_eager: nil, context_providers: nil, workflows_allow: nil,
343
+ skills_eager: nil, kits: nil, context_providers: nil, workflows_allow: nil,
334
344
  policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
335
- capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
345
+ customer_confirm: nil,
346
+ capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil, memory_retrieval: nil, shared_conversations: false,
336
347
  prompt_caching: nil, tool_persistence: nil, tool_output_compression: nil,
337
348
  fencing: nil, params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
338
349
  refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
@@ -344,16 +355,19 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
344
355
  prompt_files: Array(prompt_files), tools_allow: tools_allow,
345
356
  tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
346
357
  skills_eager: skills_eager,
358
+ kits: Array(kits).map(&:to_s).uniq,
347
359
  context_providers: context_providers, workflows_allow: workflows_allow,
348
360
  policies: normalize_policies(policies, tools_allow: tools_allow, tools_deny: tools_deny,
349
361
  tools_allow_groups: tools_allow_groups),
350
362
  prompt_refs: Array(prompt_refs),
351
363
  limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
364
+ customer_confirm: normalize_customer_confirm(customer_confirm, approvals_required),
352
365
  capabilities: capabilities,
353
366
  # opt-in like capabilities: nil => NONE. Array-normalize a present value so
354
367
  # readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
355
368
  subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
356
- tools_deferred: tools_deferred, memory: memory,
369
+ tools_deferred: tools_deferred, memory: memory, shared_conversations: shared_conversations == true,
370
+ memory_retrieval: normalize_memory_retrieval(memory_retrieval),
357
371
  prompt_caching: prompt_caching, tool_persistence: tool_persistence,
358
372
  tool_output_compression: tool_output_compression, fencing: fencing,
359
373
  # The free-form hashes arrive with symbol keys (internal build) OR string
@@ -402,10 +416,9 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
402
416
  # free-form hashes; shape-validated by the command/engine/doctor
403
417
  # (never here — the refinement precedent). nil = off (parity).
404
418
  harvest: Coercion.deep_stringify(harvest),
405
- # knowledge is profile DATA, deep-stringified like the other
406
- # free-form hashes; shape-validated by the extractor/doctor
407
- # (never here — the refinement precedent). nil = off (parity).
408
- knowledge: Coercion.deep_stringify(knowledge),
419
+ # knowledge is profile DATA. Legacy extraction remains free-form;
420
+ # an explicit rerank declaration is validated when loaded.
421
+ knowledge: normalize_knowledge(knowledge),
409
422
  # schedules is profile DATA, deep-stringified like the other
410
423
  # free-form hashes (an ARRAY of declarations); parsed into
411
424
  # Insika::Schedule entries by the engine/doctor/Studio (shape-validated
@@ -414,6 +427,46 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
414
427
  )
415
428
  end
416
429
 
430
+ def self.normalize_knowledge(value)
431
+ config = Coercion.deep_stringify(value)
432
+ return config unless config.is_a?(Hash) && config.key?("rerank")
433
+
434
+ validate_rerank(config, "knowledge")
435
+ config
436
+ end
437
+
438
+ def self.normalize_memory_retrieval(value)
439
+ return nil if value.nil?
440
+
441
+ config = Coercion.deep_stringify(value)
442
+ raise ValidationError, "memory_retrieval must be an object" unless config.is_a?(Hash)
443
+
444
+ validate_rerank(config, "memory_retrieval", require_top_k: true)
445
+ config
446
+ end
447
+
448
+ def self.validate_rerank(config, field, require_top_k: false)
449
+ rerank = config["rerank"]
450
+ raise ValidationError, "#{field}.rerank must be an object" unless rerank.is_a?(Hash)
451
+
452
+ top_k = require_top_k ? config["top_k"] : config.fetch("top_k", 5)
453
+ limit = rerank["candidate_limit"]
454
+ timeout = rerank["timeout_seconds"]
455
+ valid = top_k.is_a?(Integer) && top_k.positive? &&
456
+ limit.is_a?(Integer) && limit >= top_k && limit <= 100 &&
457
+ timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? &&
458
+ %w[provider model].all? { |key| rerank[key].is_a?(String) && !rerank[key].strip.empty? }
459
+ raise ValidationError, "invalid #{field}.rerank configuration" unless valid
460
+
461
+ require "ruby_llm"
462
+ begin
463
+ model = RubyLLM.models.find(rerank["model"], provider: rerank["provider"])
464
+ rescue RubyLLM::ModelNotFoundError
465
+ raise ValidationError, "#{field}.rerank model is not registered for provider"
466
+ end
467
+ raise ValidationError, "#{field}.rerank model does not support reranking" unless model.type == :rerank
468
+ end
469
+
417
470
  # Declaring a tool allow/deny list IS opting into it. The list is only ever
418
471
  # applied by the builtin `tool_allowlist` policy, and the Policy::Engine runs
419
472
  # ONLY the policies a profile names — so a profile with `tools_allow: [a, b]`
@@ -461,6 +514,23 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
461
514
  names
462
515
  end
463
516
 
517
+ # nil/empty -> nil (off); else [String], trimmed, uniq. A tool is confirmed by
518
+ # the customer OR approved by the operator, never both: the two hold a call in
519
+ # opposite ways (the turn ends with a question vs. the turn suspends), and one
520
+ # tool cannot do both at once.
521
+ def self.normalize_customer_confirm(list, approvals)
522
+ names = Array(list).map { |n| n.to_s.strip }.reject(&:empty?).uniq
523
+ return nil if names.empty?
524
+
525
+ both = names & Array(approvals).map(&:to_s)
526
+ unless both.empty?
527
+ raise Insika::ValidationError,
528
+ "customer_confirm and approvals_required overlap (#{both.join(', ')}): a tool is " \
529
+ "confirmed by the customer or approved by the operator, not both"
530
+ end
531
+ names
532
+ end
533
+
464
534
  # opt-in for an optional tool = being in the agent's allow list.
465
535
  def tool_opted_in?(name)
466
536
  Array(tools_allow).include?(name)
@@ -22,6 +22,17 @@ module Insika
22
22
  AUTHENTICATED_PATH = "/studio/artifacts/%{id}/content"
23
23
  SIGNED_PATH = "/studio/artifacts/s/%{id}?exp=%{exp}&sig=%{sig}"
24
24
 
25
+ # The Studio's signing config, read from the environment.
26
+ # -> { key:, ttl:, base_url: } | nil (no signing key -> no signed surface).
27
+ def studio_config
28
+ key = Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_KEY")
29
+ return nil if key.to_s.empty?
30
+
31
+ { key: key,
32
+ ttl: (Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_TTL") || 604_800).to_i,
33
+ base_url: Insika::Coercion.presence(Insika::EnvSchema.read("INSIKA_PUBLIC_URL")).to_s }
34
+ end
35
+
25
36
  # -> hex token (64 chars) | nil when the key is blank (no signed surface).
26
37
  def sign(id:, expires_at:, key:)
27
38
  key = key.to_s