insika 0.8.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -45,11 +45,36 @@ module Studio
45
45
  end
46
46
  smm = edge_int(r.params["steer_max_messages"], "steer_max_messages")
47
47
  smm.nil? ? limits.delete(:steer_max_messages) : limits[:steer_max_messages] = smm
48
+ # The debounce window is what makes `collect`/`steer` merge a burst AT THE
49
+ # DOOR; without it a steer agent only ever absorbs mid-run, at a tool
50
+ # boundary, and a turn that never calls one absorbs nothing. Same opt-in
51
+ # convention as above: blank DELETES, 0 = no window.
52
+ %w[debounce_ms debounce_max_ms].each do |field|
53
+ v = edge_int(r.params[field], field)
54
+ v.nil? ? limits.delete(field.to_sym) : limits[field.to_sym] = v
55
+ end
56
+ # Refused HERE (config time) and not at resolve time: a template without
57
+ # the placeholder drops the customer's message while the turn still looks
58
+ # steered — the same rule QueuePolicy#join! enforces, moved to where the
59
+ # operator is.
60
+ if (join = presence(r.params["steer_join"]))
61
+ unless join.include?(Insika::QueuePolicy::JOIN_PLACEHOLDER)
62
+ raise Insika::ValidationError,
63
+ "steer_join must contain #{Insika::QueuePolicy::JOIN_PLACEHOLDER}"
64
+ end
65
+
66
+ limits[:steer_join] = join
67
+ else
68
+ limits.delete(:steer_join)
69
+ end
70
+ model, provider = picked_model(r, model_ref_of(@agent.model, @agent.provider))
48
71
  {
49
72
  id: @agent.id,
50
- model: presence(r.params["model"]),
51
- provider: r.params["provider"].to_s,
73
+ model: model,
74
+ provider: provider.to_s,
52
75
  memory: r.params["memory"] == "1",
76
+ memory_retrieval: memory_retrieval_patch(r),
77
+ knowledge: knowledge_patch(r),
53
78
  limits: limits,
54
79
  params: params_patch(r),
55
80
  model_policy: model_policy_patch(r),
@@ -77,6 +102,7 @@ module Studio
77
102
  # unchecked box saves an explicit false — both spellings of ON collapse.
78
103
  tool_persistence: r.params["tool_persistence"] == "1",
79
104
  tool_output_compression: r.params["tool_output_compression"] == "1",
105
+ fencing: r.params["fencing"] == "1",
80
106
  subagents: list_patch(r, "subagents"),
81
107
  capabilities: list_patch(r, "capabilities"),
82
108
  tools_deferred: list_patch(r, "tools_deferred"),
@@ -84,11 +110,99 @@ module Studio
84
110
  context_providers: allowlist_patch(r, "context_providers"),
85
111
  workflows_allow: allowlist_patch(r, "workflows_allow"),
86
112
  approvals_required: list_patch(r, "approvals_required"),
113
+ customer_confirm: list_patch(r, "customer_confirm"),
87
114
  policies: list_patch(r, "policies").map(&:to_sym),
88
115
  prompt_refs: list_patch(r, "prompt_refs"),
89
116
  capabilities_declared: list_patch(r, "capabilities_declared"),
90
117
  skills_eager: skills_eager_patch(r)
91
- }
118
+ }.merge(inherited_fields(r))
119
+ end
120
+
121
+ # The Studio picker posts `model_ref` (checked against the list; `kept` = the
122
+ # stored ref, allowed back unchanged). Without it, the plain `model`/`provider`
123
+ # pair passes as given (API clients, older forms). -> [model | nil, provider | nil]
124
+ def picked_model(r, kept = nil)
125
+ return model_ref_parts(r.params["model"], r.params["provider"]) unless r.params.key?("model_ref")
126
+
127
+ model_ref_parts(checked_model_ref(r.params["model_ref"], kept), nil)
128
+ end
129
+
130
+ # Any other model field the Studio renders as a picker posts "<field>_ref" (one
131
+ # ref) or "<field>_refs[]" (one per slot), checked like the agent model; `kept`
132
+ # (the stored value) may come back unchanged. The plain field passes as given.
133
+ def ref_param(r, field, kept)
134
+ key = "#{field}_ref"
135
+ r.params.key?(key) ? checked_model_ref(r.params[key], kept) : presence(r.params[field])
136
+ end
137
+
138
+ def ref_list_param(r, field, kept)
139
+ key = "#{field}_refs"
140
+ return list(r.params[field]) unless r.params.key?(key)
141
+
142
+ Array(r.params[key]).filter_map { |ref| checked_model_ref(ref, Array(kept)) }
143
+ end
144
+
145
+ # ONE ref, "provider/model" (an OpenRouter model keeps its own slash:
146
+ # "openrouter/deepseek/x"). A separate `provider` wins as given.
147
+ def model_ref_parts(ref, provider)
148
+ model = presence(ref)
149
+ return [model, presence(provider)] if model.nil? || !presence(provider).nil? || !model.include?("/")
150
+
151
+ provider, model = model.split("/", 2)
152
+ [model, provider]
153
+ end
154
+
155
+ # The sections whose "use the platform default" box is checked save nil, so
156
+ # the agent keeps inheriting (Insika::AgentDefaults).
157
+ def inherited_fields(r)
158
+ (Array(r.params["inherit"]) & Insika::AgentDefaults::FIELDS).to_h { |field| [field.to_sym, nil] }
159
+ end
160
+
161
+ # The platform agent defaults form: the same sections and parsers as the
162
+ # agent form, read against the current defaults (so pack-only keys survive).
163
+ def agent_defaults_patch(r)
164
+ @agent = agent_defaults_profile
165
+ Insika::AgentDefaults::FIELDS.to_h { |field| [field, public_send(:"#{field}_patch", r)] }
166
+ end
167
+
168
+ def memory_retrieval_patch(r)
169
+ rerank = retrieval_rerank_patch(r, "memory")
170
+ return nil unless rerank
171
+
172
+ { "top_k" => retrieval_top_k(r, "memory"), "rerank" => rerank }
173
+ end
174
+
175
+ def knowledge_patch(r)
176
+ # Preserve pack options the form does not expose, such as the index.
177
+ out = (@agent.knowledge || {}).except("extract", "retrieve", "top_k", "types", "model", "prompt", "rerank")
178
+ out.merge!("extract" => r.params["knowledge_extract"] == "1",
179
+ "retrieve" => r.params["knowledge_retrieve"] == "1",
180
+ "top_k" => retrieval_top_k(r, "knowledge"))
181
+ types = list(r.params["knowledge_types"])
182
+ out["types"] = types unless types.empty?
183
+ (m = ref_param(r, "knowledge_model", (@agent.knowledge || {})["model"])) && (out["model"] = m)
184
+ (p = presence(r.params["knowledge_prompt"])) && (out["prompt"] = p)
185
+ rerank = retrieval_rerank_patch(r, "knowledge")
186
+ out["rerank"] = rerank if rerank
187
+ out
188
+ end
189
+
190
+ def retrieval_top_k(r, source)
191
+ field = "#{source}_top_k"
192
+ value = edge_int(r.params[field], field) || 5
193
+ raise Insika::ValidationError, "#{field} must be positive" unless value.positive?
194
+
195
+ value
196
+ end
197
+
198
+ def retrieval_rerank_patch(r, source)
199
+ return nil unless r.params["#{source}_rerank_enabled"] == "1"
200
+
201
+ prefix = "#{source}_rerank"
202
+ { "provider" => presence(r.params["#{prefix}_provider"]),
203
+ "model" => presence(r.params["#{prefix}_model"]),
204
+ "candidate_limit" => edge_int(r.params["#{prefix}_candidate_limit"], "#{prefix}_candidate_limit") || 20,
205
+ "timeout_seconds" => edge_float(r.params["#{prefix}_timeout_seconds"], "#{prefix}_timeout_seconds") || 2 }
92
206
  end
93
207
 
94
208
  # grounding — { "mode" => "flag"|"enforce", "matcher" =>
@@ -161,7 +275,7 @@ module Studio
161
275
  enabled = r.params["distill_enabled"] == "1"
162
276
  out = { "enabled" => enabled }
163
277
  (p = presence(r.params["distill_prompt"])) && (out["prompt"] = p)
164
- (m = presence(r.params["distill_model"])) && (out["model"] = m)
278
+ (m = ref_param(r, "distill_model", (@agent.distill || {})["model"])) && (out["model"] = m)
165
279
  %w[idle_hours min_messages max_proposals].each do |key|
166
280
  v = edge_int(r.params["distill_#{key}"], "distill_#{key}")
167
281
  out[key] = v unless v.nil?
@@ -188,7 +302,7 @@ module Studio
188
302
 
189
303
  def harvest_miner_patch(r)
190
304
  miner = {}
191
- (m = presence(r.params["harvest_miner_model"])) && (miner["model"] = m)
305
+ (m = ref_param(r, "harvest_miner_model", (@agent.harvest || {}).dig("miner", "model"))) && (miner["model"] = m)
192
306
  window = {}
193
307
  (w = edge_int(r.params["harvest_miner_window"], "harvest_miner_window")) && (window["last_sessions"] = w)
194
308
  miner["window"] = window unless window.empty?
@@ -209,7 +323,7 @@ module Studio
209
323
  (f = edge_int(r.params["refinement_max_findings"], "refinement_max_findings")) && (out["max_findings"] = f)
210
324
  files = list(r.params["refinement_files"])
211
325
  out["files"] = files unless files.empty?
212
- proposers = list(r.params["refinement_proposers"])
326
+ proposers = ref_list_param(r, "refinement_proposers", (@agent.refinement || {})["proposers"])
213
327
  out["proposers"] = proposers unless proposers.empty?
214
328
  (t = edge_int(r.params["refinement_budget_tokens"], "refinement_budget_tokens")) && (out["budget"] = { "tokens" => t })
215
329
  (e = edge_int(r.params["refinement_auto_apply_max_edits"], "refinement_auto_apply_max_edits")) && (out["auto_apply_max_edits"] = e)
@@ -236,7 +350,7 @@ module Studio
236
350
  retries = edge_int(r.params["reliability_retries"], "reliability_retries")
237
351
  backoff = presence(r.params["reliability_backoff"])
238
352
  timeout = edge_int(r.params["reliability_timeout"], "reliability_timeout")
239
- fallback = list(r.params["reliability_fallback"])
353
+ fallback = ref_list_param(r, "reliability_fallback", (@agent.reliability || {})["fallback"])
240
354
  return nil if retries.nil? && backoff.nil? && timeout.nil? && fallback.empty?
241
355
 
242
356
  out = {}
@@ -327,7 +441,8 @@ module Studio
327
441
  out = {
328
442
  "input" => r.params["guardrail_input"] == "1",
329
443
  "output" => r.params["guardrail_output"] == "1",
330
- "strictness" => presence(r.params["guardrail_strictness"]) || "medium"
444
+ "strictness" => presence(r.params["guardrail_strictness"]) || "medium",
445
+ "injection" => r.params["guardrail_injection"] == "block" ? "block" : "flag"
331
446
  }
332
447
  (mod = presence(r.params["guardrail_moderator"])) && (out["moderator"] = mod)
333
448
  responses = guardrail_responses_patch(r)
@@ -386,7 +501,7 @@ module Studio
386
501
  # record on write, so anything missing here is erased — a save that only fixed a
387
502
  # typo in the description would silently drop them (the class of bug already
388
503
  # paid for once). `stored` carries them through untouched.
389
- UNEDITED_TOOL_FIELDS = %w[group tags halt_when].freeze
504
+ UNEDITED_TOOL_FIELDS = %w[group tags halt_when evidence presentation side_effect].freeze
390
505
 
391
506
  # :write_data_tool payload from the form. nested request/response;
392
507
  # headers/query as "key=value" per line (same idiom as the MCP env —
@@ -394,12 +509,17 @@ module Studio
394
509
  # `stored` = the definition being edited (nil when creating).
395
510
  def tool_patch(r, stored = nil)
396
511
  preserved = (stored || {}).slice(*UNEDITED_TOOL_FIELDS).compact
397
- preserved.merge(
512
+ method = presence(r.params["method"]) || "GET"
513
+ # A stored side_effect describes the stored method. When the method changes
514
+ # (a GET turned POST) it is dropped so the store re-derives it — the write
515
+ # gate and the resume skip both read it.
516
+ preserved.delete("side_effect") unless method == (stored || {}).dig("request", "method")
517
+ patch = preserved.merge(
398
518
  name: presence(r.params["name"]),
399
519
  description: r.params["description"].to_s,
400
520
  parameters: parse_parameters(r.params["parameters"]),
401
521
  request: {
402
- method: presence(r.params["method"]) || "GET",
522
+ method: method,
403
523
  url: r.params["url"].to_s,
404
524
  headers: parse_kv_lines(r.params["headers"]),
405
525
  query: parse_kv_lines(r.params["query"]),
@@ -410,8 +530,17 @@ module Studio
410
530
  path: presence(r.params["path"])
411
531
  },
412
532
  secret_headers: split_list(r.params["secret_headers"]),
533
+ requires_evidence: if r.params.key?("requires_evidence")
534
+ split_list(r.params["requires_evidence"]).then { |names| names.empty? ? nil : names }
535
+ else
536
+ (stored || {})["requires_evidence"]
537
+ end,
413
538
  timeout: presence(r.params["timeout"])
414
539
  )
540
+ # A presentation tool makes no request: the form's HTTP fields are blank and
541
+ # the definition refuses both halves at once.
542
+ patch.delete(:request) if preserved["presentation"]
543
+ patch
415
544
  end
416
545
 
417
546
  # Parameters, TWO accepted syntaxes in one field (auto-detected, no mode toggle):
@@ -447,6 +576,10 @@ module Studio
447
576
  end
448
577
  end
449
578
 
579
+ def stored_compaction_model
580
+ insika[:settings_store] ? insika[:settings_store].get.dig("compaction", "model") : nil
581
+ end
582
+
450
583
  def settings_patch(r)
451
584
  patch = {
452
585
  "streaming" => r.params["streaming"] == "1"
@@ -466,7 +599,7 @@ module Studio
466
599
  # when cleared (deep_merge — the defaults backstop a fresh record);
467
600
  # model blank = nil = the platform utility_model.
468
601
  compaction = { "enabled" => r.params["compaction_enabled"] == "1",
469
- "model" => presence(r.params["compaction_model"]) }
602
+ "model" => ref_param(r, "compaction_model", stored_compaction_model) }
470
603
  %w[keep_last compact_after].each do |f|
471
604
  v = presence(r.params["compaction_#{f}"])
472
605
  compaction[f] = Integer(v) if v
@@ -488,6 +621,30 @@ module Studio
488
621
  { "edge" => edge }
489
622
  end
490
623
 
624
+ # Burst policy — the PLATFORM layer of the QueuePolicy, saved from its own form
625
+ # (a sub-resource, like edge/models). Every field writes nil when blank, and nil
626
+ # here means "no platform default" (QueuePolicy.pick skips it) — NOT 0, which on
627
+ # `steer_max_messages` would read as "never steer" deploy-wide. The two refusals
628
+ # are the agent form's, verbatim: an unknown mode and a `steer_join` that would
629
+ # drop the customer's message are config errors, caught where the operator is.
630
+ def queue_patch(r)
631
+ mode = presence(r.params["queue_mode"])
632
+ if mode && !Insika::QueuePolicy::MODES.map(&:to_s).include?(mode)
633
+ raise Insika::ValidationError, "queue_mode must be one of: #{Insika::QueuePolicy::MODES.join(', ')}"
634
+ end
635
+
636
+ join = presence(r.params["steer_join"])
637
+ if join && !join.include?(Insika::QueuePolicy::JOIN_PLACEHOLDER)
638
+ raise Insika::ValidationError, "steer_join must contain #{Insika::QueuePolicy::JOIN_PLACEHOLDER}"
639
+ end
640
+
641
+ queue = { "queue_mode" => mode, "steer_join" => join }
642
+ %w[debounce_ms debounce_max_ms steer_max_messages].each do |f|
643
+ queue[f] = edge_int(r.params[f], f)
644
+ end
645
+ { "queue" => queue }
646
+ end
647
+
491
648
  # An eval case arrives as the YAML text the operator edited — the same shape the
492
649
  # corpus files hold, so there is one format to learn and a pull request can review
493
650
  # what was authored. Decoding happens HERE, at the transport edge; the SHAPE is
@@ -558,7 +715,27 @@ module Studio
558
715
  # deep-merge writes and the ModelResolver reads as "no platform default");
559
716
  # fallback_models is a full-list replace (deep_merge substitutes arrays, so a
560
717
  # resubmit never accumulates).
718
+ # The pickers post `*_ref` fields, each checked against the list (a stored
719
+ # value may come back unchanged): default_model splits into model/provider like
720
+ # the agent's; fallback_refs[] is one picker per slot (blank slots dropped).
721
+ # Without them, the plain fields pass as given (API clients, older forms).
561
722
  def model_defaults_patch(r)
723
+ return plain_model_defaults(r) unless r.params.key?("default_model_ref")
724
+
725
+ current = insika[:settings_store] ? insika[:settings_store].get : {}
726
+ kept_default = model_ref_of(current["default_model"], current["default_provider"])
727
+ default_model, default_provider = model_ref_parts(checked_model_ref(r.params["default_model_ref"], kept_default), nil)
728
+ kept_fallbacks = Array(current["fallback_models"])
729
+ {
730
+ "default_model" => default_model,
731
+ "default_provider" => default_provider,
732
+ "utility_model" => checked_model_ref(r.params["utility_model_ref"], current["utility_model"]),
733
+ "fallback_models" => Array(r.params["fallback_refs"]).filter_map { |f| checked_model_ref(f, kept_fallbacks) },
734
+ "thinking" => presence(r.params["thinking"])
735
+ }
736
+ end
737
+
738
+ def plain_model_defaults(r)
562
739
  {
563
740
  "default_model" => presence(r.params["default_model"]),
564
741
  "default_provider" => presence(r.params["default_provider"]),
@@ -8,13 +8,16 @@ module Studio
8
8
  # 20×20 stroke icons (currentColor), one per nav key. Source: a Lucide-style
9
9
  # line icon set.
10
10
  NAV_ICONS = {
11
+ search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
11
12
  home: '<path d="M3 9.5 12 3l9 6.5"/><path d="M5 10v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V10"/><path d="M9 21v-6h6v6"/>',
12
13
  agents: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
13
14
  skills: '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z"/>',
15
+ kits: '<path d="M21 8 12 3 3 8v8l9 5 9-5z"/><path d="m3 8 9 5 9-5"/><path d="M12 13v8"/>',
14
16
  tools: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
15
17
  system: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/>',
16
18
  mcp: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
17
19
  settings: '<path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/>',
20
+ live: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
18
21
  chats: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
19
22
  playground: '<polygon points="6 3 20 12 6 21 6 3"/>',
20
23
  tasks: '<path d="M11 12H3"/><path d="M16 6H3"/><path d="M16 18H3"/><path d="m18 9 3 3-3 3"/>',
@@ -0,0 +1,73 @@
1
+ <% knowledge = @agent.knowledge || {} %>
2
+ <p class="muted">Memory keeps facts and notes across conversations. Knowledge stores reusable concepts. Reranking only changes which saved information is selected for a turn.</p>
3
+ <% [["memory", "Memory", @agent.memory_retrieval || {}], ["knowledge", "Knowledge", knowledge]].each do |source, title, config| %>
4
+ <fieldset class="subgroup">
5
+ <legend><%= title %></legend>
6
+ <%== inherit_toggle(source == "memory" ? "memory_retrieval" : "knowledge") %>
7
+ <% if source == "memory" %>
8
+ <% unless @agent_defaults_form %>
9
+ <label class="check own">
10
+ <input type="checkbox" name="memory" value="1"<%== " checked" if @agent.memory %>>
11
+ Remember across conversations
12
+ </label>
13
+ <% end %>
14
+ <p class="muted">Without reranking, memory uses saved facts and the ten most recent notes. Turning memory off does not delete them.</p>
15
+ <% else %>
16
+ <label class="check">
17
+ <input type="checkbox" name="knowledge_extract" value="1"<%== " checked" if Insika::Coercion.truthy?(knowledge["extract"]) %>>
18
+ Learn concepts from conversations
19
+ </label>
20
+ <label class="check">
21
+ <input type="checkbox" name="knowledge_retrieve" value="1"<%== " checked" if Insika::Coercion.truthy?(knowledge["retrieve"]) %>>
22
+ Use knowledge in replies
23
+ </label>
24
+ <p class="muted">Learning and retrieval work independently. Turning them off does not delete saved concepts.</p>
25
+ <label>Concepts selected per turn
26
+ <input type="number" name="knowledge_top_k" value="<%= knowledge.fetch("top_k", 5) %>" min="1" step="1">
27
+ </label>
28
+ <details class="newfile">
29
+ <summary>Knowledge learning settings</summary>
30
+ <div class="grid-2">
31
+ <label>Concept types <span class="muted">— comma-separated; blank = default types</span>
32
+ <input type="text" name="knowledge_types" value="<%= Array(knowledge["types"]).join(", ") %>" placeholder="fact, policy, objection">
33
+ </label>
34
+ <label>Learning model <span class="muted">— blank = platform utility model</span>
35
+ <%== model_select("knowledge_model_ref", knowledge["model"], "platform utility model") %>
36
+ </label>
37
+ </div>
38
+ <label>Learning instructions <span class="muted">— blank = default instructions</span>
39
+ <textarea name="knowledge_prompt" rows="4"><%= knowledge["prompt"] %></textarea>
40
+ </label>
41
+ </details>
42
+ <% end %>
43
+ <% rerank = config["rerank"] || {} %>
44
+ <label class="check">
45
+ <input type="checkbox" name="<%= source %>_rerank_enabled" value="1"<%== " checked" if config["rerank"] %>>
46
+ Rerank <%= source %> by relevance
47
+ </label>
48
+ <p class="muted">Off by default. Adds a provider call and may increase cost. Requires <%= source == "memory" ? "memory" : "knowledge retrieval" %> to be on. Provider credentials are configured separately.</p>
49
+ <details class="newfile"<%== " open" if config["rerank"] %>>
50
+ <summary><%= title %> rerank settings</summary>
51
+ <div class="grid-2">
52
+ <label>Rerank provider
53
+ <input type="text" name="<%= source %>_rerank_provider" value="<%= rerank["provider"] %>" placeholder="cohere">
54
+ </label>
55
+ <label>Rerank model
56
+ <input type="text" name="<%= source %>_rerank_model" value="<%= rerank["model"] %>" placeholder="rerank-v3.5">
57
+ </label>
58
+ <% if source == "memory" %>
59
+ <label>Memory items selected per turn
60
+ <input type="number" name="memory_top_k" value="<%= config.fetch("top_k", 5) %>" min="1" max="100" step="1">
61
+ </label>
62
+ <% end %>
63
+ <label>Candidate limit <span class="muted">— at least the selected count, up to 100</span>
64
+ <input type="number" name="<%= source %>_rerank_candidate_limit" value="<%= rerank.fetch("candidate_limit", 20) %>" min="1" max="100" step="1">
65
+ </label>
66
+ <label>Rerank timeout (seconds)
67
+ <input type="number" name="<%= source %>_rerank_timeout_seconds" value="<%= rerank.fetch("timeout_seconds", 2) %>" min="0.001" step="any">
68
+ </label>
69
+ </div>
70
+ <p class="muted">Choose a rerank model registered for this provider. On failure, the agent uses the usual retrieval. Disabling rerank clears these rerank settings.</p>
71
+ </details>
72
+ </fieldset>
73
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <%# Type-to-search agent picker: an <input list> over a <datalist> — native
2
+ search in every browser, no JS. Locals: name, options (ids), value,
3
+ blank (placeholder meaning "none / all"; nil = a value is required),
4
+ auto_submit (submit the form when a pick is committed). The server still
5
+ validates the value: a typed id that is not an agent is refused there. %>
6
+ <%# unique per page: a page can carry two pickers (a filter and a form) %>
7
+ <% list_id = "agent-options-#{name}-#{@agent_picker_seq = (@agent_picker_seq || 0) + 1}" %>
8
+ <% blank = defined?(blank) ? blank : nil %>
9
+ <% auto_submit = defined?(auto_submit) && auto_submit %>
10
+ <input type="search" class="filter-input agent-picker" name="<%= name %>" list="<%= list_id %>" value="<%= value %>" autocomplete="off"
11
+ placeholder="<%= blank || "Type to search agents…" %>"<%== " required" unless blank %>
12
+ <%== %(data-controller="auto-submit" data-action="change->auto-submit#submit") if auto_submit %>>
13
+ <datalist id="<%= list_id %>">
14
+ <% Array(options).each do |id| %><option value="<%= id %>"></option><% end %>
15
+ </datalist>