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
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Refinement
5
+ # What a refinement run is allowed to SPEND.
6
+ #
7
+ # A panel of 3 proposers over a 7-case golden set is 3 model calls plus 21
8
+ # replayed conversations, each a real turn with real tools. That is the honest
9
+ # objection to the whole feature, and this is the answer to it: a ceiling the
10
+ # operator sets, checked before each expensive step and never in the middle of
11
+ # one. Nothing is aborted mid-flight — a half-scored candidate is worse than an
12
+ # unscored one, because it looks like a verdict.
13
+ #
14
+ # **Unmetered legs are counted, not guessed.** A provider that reports no token
15
+ # usage makes a leg invisible to the ceiling; recording it as 0 would let a budget
16
+ # sit at "0 spent" forever while real money went out, so those legs are tallied
17
+ # separately and shown to the operator. The structural bounds — the fan-out cap on
18
+ # the panel, `max_edits`, the gate's own refusals — are what bound a run whose
19
+ # provider says nothing.
20
+ class Budget
21
+ def initialize(tokens: nil)
22
+ limit = tokens.to_i
23
+ @limit = limit.positive? ? limit : nil
24
+ @spent = 0
25
+ @cached = 0
26
+ @unmetered = 0
27
+ end
28
+
29
+ def limited? = !@limit.nil?
30
+
31
+ # `tokens` is what the leg SENT, prompt cache included — see
32
+ # `Evals::Runner#billed_tokens` for why the engine's `total_tokens` alone is not
33
+ # that number. `cached` is the part of it that was cached, carried so the record
34
+ # can explain a total rather than just state one. nil/0 tokens = the leg
35
+ # happened and reported nothing.
36
+ def spend(tokens, cached: nil)
37
+ count = tokens.to_i
38
+ if count.positive?
39
+ @spent += count
40
+ @cached += cached.to_i
41
+ else
42
+ @unmetered += 1
43
+ end
44
+ self
45
+ end
46
+
47
+ def exhausted? = limited? && @spent >= @limit
48
+
49
+ def remaining = limited? ? [@limit - @spent, 0].max : nil
50
+
51
+ def to_h = { "tokens" => @limit, "spent" => @spent, "cached" => @cached,
52
+ "unmetered" => @unmetered }
53
+ end
54
+
55
+ # The proposer PANEL: N models write N independent
56
+ # candidates, the gate scores each one by replaying the golden set, and the best
57
+ # SURVIVOR becomes the proposal a human is asked about.
58
+ #
59
+ # Independent, not consensus-seeking. Two models agreeing on wording is weak
60
+ # evidence and a golden case passing is strong evidence, so convergence only
61
+ # ever breaks a tie between candidates the gate already ranked equal.
62
+ #
63
+ # A panel of one is unchanged, which is why there is no second code path:
64
+ # `refinement.proposer` (a single ref) resolves to a one-element panel.
65
+ class Panel
66
+ # One member of the panel: the candidate, WHO wrote it (more than one model when
67
+ # they converged on the identical edit set), and how it scored.
68
+ Entry = Data.define(:candidate, :proposers, :report) do
69
+ def converged = proposers.size
70
+ def passed? = report&.passed == true
71
+
72
+ def to_h = { "candidate" => candidate.to_h, "proposers" => proposers,
73
+ "gate" => report&.to_h }
74
+ end
75
+
76
+ Result = Data.define(:entries, :winner, :budget, :failed) do
77
+ def winner_report = winner&.report
78
+ end
79
+
80
+ # gate: a Refinement::Gate (anything answering #score).
81
+ # proposers: [Refinement::Proposer], already resolved by ProposerFactory.panel.
82
+ # budget: a Budget. The default is unlimited — a deployment that configured
83
+ # none gets behaviour, which had no ceiling either.
84
+ def initialize(gate:, proposers: [], budget: Budget.new, fan_out: nil)
85
+ @gate = gate
86
+ @proposers = Array(proposers)
87
+ @budget = budget
88
+ @fan_out = fan_out || Insika::SubagentGraph.fan_out_cap
89
+ end
90
+
91
+ # Proposes (unless `raw` is given), builds, gates and ranks. Yields the built
92
+ # entries BEFORE any of them is scored, so the caller can record the panel and
93
+ # move the run to :gating — the gate is the slow part and a run that says
94
+ # nothing until it finishes looks hung.
95
+ #
96
+ # -> Result. Raises ValidationError when there is nothing gateable at all,
97
+ # because that is an operator-facing refusal ("every edit was dropped, here is
98
+ # why"), not a verdict about the agent.
99
+ def run(agent_id:, run_id:, findings:, files:, allowlist:, contents:, limits: {},
100
+ raw: nil, tolerance: nil)
101
+ proposals, failed = raw ? [[raw], []] : propose(agent_id, findings, files, limits)
102
+ # A candidate that ARRIVED (Studio form, API client) cost this run nothing —
103
+ # counting it as an unmetered leg would make the cost record read as if a model
104
+ # had been asked and stayed quiet.
105
+ entries = build(proposals, allowlist, contents, limits, metered: raw.nil?)
106
+ yield entries if block_given?
107
+
108
+ scored = entries.map { |entry| score(entry, agent_id, run_id, tolerance) }
109
+ Result.new(entries: scored, winner: self.class.rank(scored), budget: @budget, failed: failed)
110
+ end
111
+
112
+ # -> the best SURVIVOR, or nil when none passed.
113
+ #
114
+ # Highest graded score first; ties broken by the fewest edits (a smaller diff is
115
+ # a smaller bet), then by how many proposers converged on it. `min_by`
116
+ # over a negated tuple keeps the comparison in one place and stays stable, so
117
+ # two genuinely indistinguishable candidates resolve to the first proposer the
118
+ # operator listed rather than to whichever fiber finished first.
119
+ def self.rank(entries)
120
+ entries.select(&:passed?)
121
+ .min_by { |e| [-e.report.passed_cases, e.candidate.edits.size, -e.converged] }
122
+ end
123
+
124
+ # The refusal to RECORD when nothing survived: the entry that got furthest,
125
+ # because "1 regression on `quotes`" tells an operator something and "budget
126
+ # exhausted" tells them only that the run stopped.
127
+ def self.best_refusal(entries)
128
+ entries.find { |e| e.report&.cases.to_i.positive? } ||
129
+ entries.find { |e| e.report } || entries.first
130
+ end
131
+
132
+ private
133
+
134
+ # All proposers at once, bounded by the fan-out cap (says the
135
+ # panel reuses it). Each is one blocking HTTP call to a provider, so the
136
+ # wall-clock is the slowest model rather than their sum.
137
+ #
138
+ # A proposer that answers prose, times out or 500s takes ITSELF out of the panel
139
+ # and nothing else: the whole point of asking several models is that one of them
140
+ # being useless is a survivable event. All of them failing is not, and raises.
141
+ def propose(agent_id, findings, files, limits)
142
+ raise Insika::ValidationError, "no proposer is configured" if @proposers.empty?
143
+
144
+ require_relative "../tools/concurrency"
145
+ blocks = @proposers.map do |proposer|
146
+ lambda do
147
+ proposer.propose(agent_id: agent_id, findings: findings, files: files, limits: limits)
148
+ rescue StandardError => e
149
+ { "failed" => "#{proposer.model}: #{e.class}: #{e.message}" }
150
+ end
151
+ end
152
+
153
+ outcomes = Insika::Tools::Concurrency.gather(blocks, max: @fan_out)
154
+ proposals, failures = outcomes.partition { |o| o["failed"].nil? }
155
+ failed = failures.map { |o| o["failed"] }
156
+ if proposals.empty?
157
+ raise Insika::ValidationError,
158
+ "every proposer failed — #{failed.join('; ')}"
159
+ end
160
+
161
+ [proposals, failed]
162
+ end
163
+
164
+ # Raw candidates -> Entries, DEDUPED by their edit set. Two models that wrote
165
+ # the identical edit are one candidate with two proposers: gating it twice would
166
+ # spend a whole golden replay to learn the same number, and the fact that they
167
+ # agreed is worth more as a tie-break than as a second row.
168
+ def build(proposals, allowlist, contents, limits, metered: true)
169
+ entries = {}
170
+ dropped = []
171
+
172
+ proposals.each do |proposal|
173
+ candidate = CandidateBuilder.build(proposal, allowlist: allowlist,
174
+ contents: contents, limits: limits)
175
+ if metered
176
+ raw = proposal.is_a?(Hash) ? proposal : {}
177
+ @budget.spend(raw["tokens"], cached: raw["cached"])
178
+ end
179
+ if candidate.empty?
180
+ dropped.concat(candidate.dropped)
181
+ next
182
+ end
183
+
184
+ signature = candidate.edits.map(&:to_h)
185
+ if (seen = entries[signature])
186
+ entries[signature] = seen.with(proposers: seen.proposers + [candidate.proposer])
187
+ else
188
+ entries[signature] = Entry.new(candidate: candidate, proposers: [candidate.proposer],
189
+ report: nil)
190
+ end
191
+ end
192
+
193
+ return entries.values unless entries.empty?
194
+
195
+ # Every edit of every proposal fell off. Say which and why: an operator who
196
+ # gets "invalid candidate" back learns nothing, and a stale `before` is the
197
+ # common one.
198
+ raise Insika::ValidationError,
199
+ "every edit was dropped — #{dropped.map { |d| "#{d.file}: #{d.reason}" }.join('; ')}"
200
+ end
201
+
202
+ # One gate run, unless the budget is already spent. An unscored candidate is
203
+ # recorded with a refusal that names the ceiling — never dropped in silence,
204
+ # which would read as "the panel only produced one idea".
205
+ def score(entry, agent_id, run_id, tolerance)
206
+ if @budget.exhausted?
207
+ return entry.with(report: Gate::Report.new(
208
+ candidate_id: entry.candidate.id, passed: false,
209
+ reason: "not gated — the run's token budget (#{@budget.to_h['tokens']}) was spent",
210
+ cases: 0, passed_cases: 0, baseline_cases: 0, regressions: [], report: nil,
211
+ tokens: nil, cached: nil
212
+ ))
213
+ end
214
+
215
+ report = @gate.score(agent_id: agent_id, candidate: entry.candidate,
216
+ run_id: run_id, tolerance: tolerance)
217
+ @budget.spend(report.tokens, cached: report.cached)
218
+ entry.with(report: report)
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ module Refinement
7
+ # Writes a CANDIDATE from a run's findings — the
8
+ # one place in refinement where a model is asked for anything.
9
+ #
10
+ # It is deliberately the WEAKEST link and it is built that way: everything this
11
+ # class produces is data that the CandidateBuilder bounds (allowlist, size,
12
+ # growth, a `before` that must still match) and the Gate then scores by REPLAYING
13
+ # the golden set. A hallucinated rationale, a misread finding, an invented anchor
14
+ # — the worst outcome of each is a candidate that gets dropped or fails to improve
15
+ # a score, and never reaches a customer. Nothing here is trusted; it is measured.
16
+ #
17
+ # Pure over an injected `ask` (prompt -> raw model text), like `Evals::Judge` and
18
+ # `Safety::Factory`: unit-testable without a provider, and the real ask is one
19
+ # lambda built by `ProposerFactory`.
20
+ #
21
+ # ## What it is shown
22
+ #
23
+ # The findings (already redacted at collection — a snippet went through the same
24
+ # output filter a customer-facing turn does) and the CURRENT CONTENT of the
25
+ # allowlisted files, verbatim and unmasked. That is not a leak: those files are
26
+ # the agent's own instructions, which are sent to a model on every single turn.
27
+ # Masking them here would buy nothing and would break anchoring — a `before`
28
+ # copied from a masked view never matches the real file, so every edit near a
29
+ # secret would drop as stale.
30
+ #
31
+ # Files OUTSIDE the allowlist are not shown at all. A model that can read them
32
+ # proposes edits to them, which drop, which spends the operator's attention on
33
+ # rejects.
34
+ class Proposer
35
+ # A model that answers with prose instead of JSON produces NOTHING, loudly.
36
+ # Silently returning an empty candidate would read as "the traffic is fine".
37
+ class Unusable < Insika::ValidationError; end
38
+
39
+ MAX_FINDINGS = 10
40
+
41
+ # The model ref, so a panel can name WHICH proposer failed without guessing.
42
+ attr_reader :model
43
+
44
+ # ask: ->(prompt) { "<raw model text>" }, or something answering `#content`
45
+ # plus `#input_tokens`/`#output_tokens` (a RubyLLM message). The second
46
+ # shape is what lets the panel's budget count what a proposal cost; a
47
+ # plain String stays valid and simply reports no cost, which is what
48
+ # every existing caller and every fake does.
49
+ # model: what to record as the candidate's `proposer` — the ref an operator
50
+ # reads on the review card and in `:refinement_proposed`.
51
+ def initialize(ask:, model: "unknown")
52
+ @ask = ask
53
+ @model = model.to_s
54
+ end
55
+
56
+ # -> a RAW candidate hash (string keys) for `CandidateBuilder.build`. It is not
57
+ # a Candidate: this class does not get to decide what is in bounds.
58
+ #
59
+ # findings: the run's findings as stored (string keys).
60
+ # files: name => current content, allowlist only.
61
+ def propose(agent_id:, findings:, files:, limits: {})
62
+ raise Unusable, "there is nothing to propose from — the run found no findings" if Array(findings).empty?
63
+ raise Unusable, "no writable file has any content to anchor an edit in" if files.empty?
64
+
65
+ answer = @ask.call(build_prompt(agent_id, Array(findings).first(MAX_FINDINGS), files, limits))
66
+ parsed = parse(text_of(answer))
67
+ parsed["proposer"] = @model
68
+ parsed["tokens"] = tokens_of(answer)
69
+ parsed["cached"] = cached_of(answer)
70
+ parsed
71
+ end
72
+
73
+ private
74
+
75
+ def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
76
+
77
+ # nil when the provider said nothing — never 0. `Budget` distinguishes the two
78
+ # and an operator reading "0 tokens" for a real model call would be reading a
79
+ # lie the record cannot correct. The cached prefix is INCLUDED, for the same
80
+ # reason `Evals::Runner#billed_tokens` includes it: a ceiling that cannot see
81
+ # what the cache served is not a ceiling on what was sent.
82
+ def tokens_of(answer)
83
+ return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
84
+
85
+ total = answer.input_tokens.to_i + answer.output_tokens.to_i + cached_of(answer).to_i
86
+ total.positive? ? total : nil
87
+ end
88
+
89
+ def cached_of(answer)
90
+ return nil unless answer.respond_to?(:cached_tokens)
91
+
92
+ cached = answer.cached_tokens.to_i
93
+ cached.positive? ? cached : nil
94
+ end
95
+
96
+ # JSON or nothing. ````json` fences are the common wrapper and stripping them is
97
+ # not leniency — the payload inside is still parsed strictly, so a model that
98
+ # improvises a schema fails here instead of producing half a candidate.
99
+ def parse(raw)
100
+ body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
101
+ first = body.index("{")
102
+ last = body.rindex("}")
103
+ raise Unusable, "the proposer answered with no JSON object" if first.nil? || last.nil? || last < first
104
+
105
+ parsed = JSON.parse(body[first..last])
106
+ raise Unusable, "the proposer's JSON is not an object" unless parsed.is_a?(Hash)
107
+ raise Unusable, "the proposer's JSON carries no `edits`" unless parsed["edits"].is_a?(Array)
108
+
109
+ parsed
110
+ rescue JSON::ParserError => e
111
+ raise Unusable, "the proposer's answer is not valid JSON: #{e.message}"
112
+ end
113
+
114
+ def build_prompt(agent_id, findings, files, limits)
115
+ bounds = DEFAULT_LIMITS.merge(Coercion.deep_stringify(limits.is_a?(Hash) ? limits : {}))
116
+ <<~PROMPT
117
+ You are improving the written instructions of a production customer-service agent
118
+ called "#{agent_id}". You are given (1) what actually broke in its recent real
119
+ traffic and (2) the current text of the only files you may edit.
120
+
121
+ Propose the SMALLEST set of anchored edits that would plausibly fix the findings.
122
+ Your proposal will be scored by replaying the agent's test cases with your edits
123
+ applied — plausible prose that changes no behaviour is worth nothing here.
124
+
125
+ ## What broke
126
+
127
+ #{render_findings(findings)}
128
+
129
+ ## Files you may edit (current content, verbatim)
130
+
131
+ #{render_files(files)}
132
+
133
+ ## Rules
134
+
135
+ - Answer with a single JSON object and NOTHING else. No prose, no fences.
136
+ - At most #{bounds['max_edits']} edits. Each `after` at most #{bounds['max_bytes']} bytes.
137
+ - `op` is "replace" or "append". Never rewrite a whole file.
138
+ - For "replace": `before` MUST be copied character-for-character from the file
139
+ above and must appear there EXACTLY ONCE. If you cannot find a unique anchor,
140
+ use "append" instead. An edit whose `before` does not match is discarded.
141
+ - For "append": `before` is ignored; the text is added at the end of the file.
142
+ - `file` must be one of the file names listed above.
143
+ - `addresses` lists the findings the edit is meant to fix, by their `kind` and
144
+ subject (e.g. "tool_error:shipping_quote").
145
+ - Do not propose an edit you cannot justify from a finding above. Fewer, better
146
+ edits beat filling the quota.
147
+ - You cannot remove a tool, change a guardrail or a model with these edits —
148
+ those are configuration, not text. Do not write instructions that pretend to.
149
+ - Some findings are INFRASTRUCTURE, not behaviour: a tool that failed on the
150
+ network, on TLS, on a refused connection, on a timeout, on a blocked
151
+ destination or on an HTTP status. The assistant does not choose URLs,
152
+ schemes, hosts or credentials and cannot fix any of that by being told to.
153
+ You may say what to DO when a tool fails; never say how to call it correctly.
154
+
155
+ ## Answer with exactly this shape
156
+
157
+ {"rationale": "one or two sentences on the cause you are addressing",
158
+ "edits": [{"file": "TOOLS.md", "op": "replace", "anchor": "## shipping_quote",
159
+ "before": "<text copied from the file>",
160
+ "after": "<the replacement>",
161
+ "addresses": ["tool_error:shipping_quote"]}]}
162
+ PROMPT
163
+ end
164
+
165
+ # Counts included: "this happened 24 times" is the difference between a defect
166
+ # worth a prompt edit and a one-off the operator should ignore.
167
+ def render_findings(findings)
168
+ findings.map do |f|
169
+ f = Coercion.deep_stringify(f.respond_to?(:to_h) ? f.to_h : f)
170
+ line = "- #{f['kind']} (×#{f['count']}): #{f['title']}"
171
+ line += "\n #{f['detail']}" if Coercion.present?(f["detail"])
172
+ line
173
+ end.join("\n")
174
+ end
175
+
176
+ def render_files(files)
177
+ files.map { |name, body| "### #{name}\n\n```\n#{body}\n```" }.join("\n\n")
178
+ end
179
+ end
180
+
181
+ # Resolves WHICH model(s) write the candidate, and builds the ask.
182
+ #
183
+ # refinement.proposers on the agent (a PANEL)
184
+ # -> refinement.proposer ("deepseek/deepseek-v4-flash" | "deepseek-v4-flash")
185
+ # -> the platform utility_model
186
+ # -> nothing, and the caller refuses. There is no default model here on purpose:
187
+ # guessing one spends an operator's provider budget without being asked.
188
+ module ProposerFactory
189
+ module_function
190
+
191
+ # config: the agent's `refinement` hash. -> Proposer | nil (the FIRST of the
192
+ # panel — single-proposer entry point, kept because a deployment that
193
+ # never configured a panel is a panel of one).
194
+ def build(config, utility_model: nil, ask_factory: nil, llm: nil)
195
+ panel(config, utility_model: utility_model, ask_factory: ask_factory, llm: llm).first
196
+ end
197
+
198
+ # -> [Proposer], in configured order, DEDUPED by model ref and capped at the
199
+ # fan-out (says the panel reuses it). Two entries naming the same
200
+ # model are one proposer: asking the same model twice at temperature 0 measures
201
+ # its variance, which is exactly what rejected for the judges.
202
+ # `llm`: the graph's own RubyLLM context; nil = the global
203
+ # constant. Today only the deployment root builds a panel, and a deployment
204
+ # is one graph per process — the seam exists so an embedded graph that ever
205
+ # gains the refinement commands proposes on its own credentials.
206
+ def panel(config, utility_model: nil, ask_factory: nil, max: nil, llm: nil)
207
+ refs = refs_for(Coercion.deep_stringify(config || {}), utility_model)
208
+ cap = max || Insika::SubagentGraph.fan_out_cap
209
+ factory = ask_factory || ->(model, provider) { ruby_llm_ask(model, provider, llm: llm) }
210
+ refs.first(cap).map do |ref|
211
+ provider, model = split_ref(ref)
212
+ Proposer.new(ask: factory.call(model, provider), model: ref)
213
+ end
214
+ end
215
+
216
+ # `proposers` accepts either syntax — a bare ref ("deepseek/deepseek-v4-flash") or
217
+ # the RFC's `{ "model" =>, "provider"? => }` — because the two already coexist in
218
+ # this config (`proposer` is a bare ref, `judges` are hashes) and refusing one of
219
+ # them would only teach operators which page they were reading.
220
+ def refs_for(config, utility_model)
221
+ listed = Array(config["proposers"]).filter_map { |entry| normalize_ref(entry) }
222
+ return listed.uniq unless listed.empty?
223
+
224
+ [Coercion.presence(config["proposer"]) || Coercion.presence(utility_model)].compact
225
+ end
226
+
227
+ def normalize_ref(entry)
228
+ return Coercion.presence(entry) unless entry.is_a?(Hash)
229
+
230
+ e = Coercion.deep_stringify(entry)
231
+ model = Coercion.presence(e["model"])
232
+ return nil if model.nil?
233
+
234
+ (provider = Coercion.presence(e["provider"])) ? "#{provider}/#{model}" : model
235
+ end
236
+
237
+ # "provider/model" -> [provider, model]; "model" -> [nil, model]. Same reading
238
+ # `Safety::Factory` uses for the moderator — one syntax for "which model", not
239
+ # one per feature.
240
+ def split_ref(ref)
241
+ prov, name = ref.to_s.split("/", 2)
242
+ name ? [prov, name] : [nil, prov]
243
+ end
244
+
245
+ # Temperature 0: a proposal an operator rejects should be re-proposable, and a
246
+ # gate result should be attributable to the edit rather than to a sampling seed.
247
+ # `ruby_llm` is required lazily so nothing loads a provider gem until a proposer
248
+ # is actually configured.
249
+ #
250
+ # Returns the MESSAGE, not `.content`: the token counts ride on it and the
251
+ # budget is what spends them. `Proposer` reads either shape.
252
+ def ruby_llm_ask(model, provider, llm: nil)
253
+ require "ruby_llm"
254
+ llm ||= RubyLLM
255
+ lambda do |prompt|
256
+ llm.chat(model: model, provider: provider, assume_model_exists: true)
257
+ .with_temperature(0).ask(prompt)
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end