insika 0.1.0 → 0.3.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Insika
4
4
  module Refinement
5
- # Scores a candidate by RUNNING it (RFC-0013 §3.5). Not by asking a model whether
6
- # the edit looks good — that measures nothing, and D3 says so in one line.
5
+ # Scores a candidate by RUNNING it. Not by asking a model whether
6
+ # the edit looks good — that measures nothing, and says so in one line.
7
7
  #
8
8
  # 1. clone the agent into a throwaway id (`<agent>-cand-<run8>`)
9
9
  # 2. copy its instruction files, apply the candidate's edits to the COPY
@@ -27,7 +27,7 @@ module Insika
27
27
  # it — nil when no turn carried usage. `cached` is how much of that came from
28
28
  # the prompt cache, kept separate because it is what explains one candidate
29
29
  # costing 8× another over the same cases. `tokens` is what the panel's budget
30
- # (§3.9) spends and what the operator reads on the run: a gate is the expensive
30
+ # spends and what the operator reads on the run: a gate is the expensive
31
31
  # half of refinement and a loop whose cost is invisible is one nobody can decide
32
32
  # to keep.
33
33
  Report = Data.define(:candidate_id, :passed, :reason, :cases, :passed_cases,
@@ -48,9 +48,9 @@ module Insika
48
48
  # credential the operator can rotate.
49
49
  # capabilities_factory: -> an `Evals::HttpCapabilities` for the clone, or nil.
50
50
  # Without it a case whose `requires` the agent cannot satisfy RUNS and fails
51
- # (RFC-0014 §3.2 says it must skip) — and then the gate and `evals/run.rb`, the
51
+ # (says it must skip) — and then the gate and `evals/run.rb`, the
52
52
  # two callers of the one evaluator, disagree about what the corpus even
53
- # measures. §3.7 exists to prevent exactly that.
53
+ # measures. exists to prevent exactly that.
54
54
  def initialize(profiles:, agent_files:, goldens:, baselines:, transport_factory:,
55
55
  capabilities_factory: nil, judge_factory: nil, tolerance: DEFAULT_TOLERANCE)
56
56
  @profiles = profiles
@@ -68,7 +68,7 @@ module Insika
68
68
  # not gate is more useful than an exception in a log.
69
69
  def score(agent_id:, candidate:, run_id:, tolerance: nil)
70
70
  cases = @goldens.for_agent(agent_id)
71
- return refusal(candidate, "the agent has no golden cases — nothing to gate against (RFC-0013 D4)") if cases.empty?
71
+ return refusal(candidate, "the agent has no golden cases — nothing to gate against") if cases.empty?
72
72
 
73
73
  baseline = @baselines.get(agent_id)
74
74
  # Without an accepted state, `Baseline.compare` compares nothing and reports
@@ -109,7 +109,7 @@ module Insika
109
109
  # Measured, not reasoned: gating the real pilot agent with `settings["evals"]`
110
110
  # unset reported **6/6, no regression** against a baseline the same corpus had
111
111
  # just scored **2/6** — `produto-sem-cep` was judged 0.0 and "passed". Both
112
- # candidates on the panel cleared. That is §3.7's failure exactly: the CLI and
112
+ # candidates on the panel cleared. That is's failure exactly: the CLI and
113
113
  # the gate, the two callers of the one evaluator, disagreeing about what the
114
114
  # corpus measures.
115
115
  judge = @judge_factory&.call
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Insika
4
4
  module Refinement
5
- # What a refinement run is allowed to SPEND (RFC-0013 §3.9, phase D).
5
+ # What a refinement run is allowed to SPEND.
6
6
  #
7
7
  # A panel of 3 proposers over a 7-case golden set is 3 model calls plus 21
8
8
  # replayed conversations, each a real turn with real tools. That is the honest
@@ -52,15 +52,15 @@ module Insika
52
52
  "unmetered" => @unmetered }
53
53
  end
54
54
 
55
- # The proposer PANEL (RFC-0013 §3.9 / §3.5): N models write N independent
55
+ # The proposer PANEL: N models write N independent
56
56
  # candidates, the gate scores each one by replaying the golden set, and the best
57
57
  # SURVIVOR becomes the proposal a human is asked about.
58
58
  #
59
59
  # Independent, not consensus-seeking. Two models agreeing on wording is weak
60
- # evidence and a golden case passing is strong evidence (D7), so convergence only
60
+ # evidence and a golden case passing is strong evidence, so convergence only
61
61
  # ever breaks a tie between candidates the gate already ranked equal.
62
62
  #
63
- # A panel of one is phase C unchanged, which is why there is no second code path:
63
+ # A panel of one is unchanged, which is why there is no second code path:
64
64
  # `refinement.proposer` (a single ref) resolves to a one-element panel.
65
65
  class Panel
66
66
  # One member of the panel: the candidate, WHO wrote it (more than one model when
@@ -80,7 +80,7 @@ module Insika
80
80
  # gate: a Refinement::Gate (anything answering #score).
81
81
  # proposers: [Refinement::Proposer], already resolved by ProposerFactory.panel.
82
82
  # budget: a Budget. The default is unlimited — a deployment that configured
83
- # none gets phase C's behaviour, which had no ceiling either.
83
+ # none gets behaviour, which had no ceiling either.
84
84
  def initialize(gate:, proposers: [], budget: Budget.new, fan_out: nil)
85
85
  @gate = gate
86
86
  @proposers = Array(proposers)
@@ -112,7 +112,7 @@ module Insika
112
112
  # -> the best SURVIVOR, or nil when none passed.
113
113
  #
114
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 (§3.5). `min_by`
115
+ # a smaller bet), then by how many proposers converged on it. `min_by`
116
116
  # over a negated tuple keeps the comparison in one place and stays stable, so
117
117
  # two genuinely indistinguishable candidates resolve to the first proposer the
118
118
  # operator listed rather than to whichever fiber finished first.
@@ -131,7 +131,7 @@ module Insika
131
131
 
132
132
  private
133
133
 
134
- # All proposers at once, bounded by the RFC-0010 fan-out cap (§3.9 says the
134
+ # All proposers at once, bounded by the fan-out cap (says the
135
135
  # panel reuses it). Each is one blocking HTTP call to a provider, so the
136
136
  # wall-clock is the slowest model rather than their sum.
137
137
  #
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Insika
6
6
  module Refinement
7
- # Writes a CANDIDATE from a run's findings (RFC-0013 §3.4, phase C / PR 3b) — the
7
+ # Writes a CANDIDATE from a run's findings — the
8
8
  # one place in refinement where a model is asked for anything.
9
9
  #
10
10
  # It is deliberately the WEAKEST link and it is built that way: everything this
@@ -180,8 +180,8 @@ module Insika
180
180
 
181
181
  # Resolves WHICH model(s) write the candidate, and builds the ask.
182
182
  #
183
- # refinement.proposers on the agent (phase D: a PANEL, RFC-0013 §3.9)
184
- # -> refinement.proposer ("deepseek/deepseek-chat" | "deepseek-chat")
183
+ # refinement.proposers on the agent (a PANEL)
184
+ # -> refinement.proposer ("deepseek/deepseek-v4-flash" | "deepseek-v4-flash")
185
185
  # -> the platform utility_model
186
186
  # -> nothing, and the caller refuses. There is no default model here on purpose:
187
187
  # guessing one spends an operator's provider budget without being asked.
@@ -189,17 +189,17 @@ module Insika
189
189
  module_function
190
190
 
191
191
  # config: the agent's `refinement` hash. -> Proposer | nil (the FIRST of the
192
- # panel — phase C's single-proposer entry point, kept because a deployment that
192
+ # panel — single-proposer entry point, kept because a deployment that
193
193
  # never configured a panel is a panel of one).
194
194
  def build(config, utility_model: nil, ask_factory: nil, llm: nil)
195
195
  panel(config, utility_model: utility_model, ask_factory: ask_factory, llm: llm).first
196
196
  end
197
197
 
198
198
  # -> [Proposer], in configured order, DEDUPED by model ref and capped at the
199
- # RFC-0010 fan-out (§3.9 says the panel reuses it). Two entries naming the same
199
+ # fan-out (says the panel reuses it). Two entries naming the same
200
200
  # model are one proposer: asking the same model twice at temperature 0 measures
201
- # its variance, which is exactly what D6 rejected for the judges.
202
- # `llm` (RFC-0017 A2): the graph's own RubyLLM context; nil = the global
201
+ # its variance, which is exactly what rejected for the judges.
202
+ # `llm`: the graph's own RubyLLM context; nil = the global
203
203
  # constant. Today only the deployment root builds a panel, and a deployment
204
204
  # is one graph per process — the seam exists so an embedded graph that ever
205
205
  # gains the refinement commands proposes on its own credentials.
@@ -213,8 +213,8 @@ module Insika
213
213
  end
214
214
  end
215
215
 
216
- # `proposers` accepts either syntax — a bare ref ("deepseek/deepseek-chat") or
217
- # the RFC's `{ "model" =>, "provider"? => }` — because the two already coexist in
216
+ # `proposers` accepts either syntax — a bare ref ("deepseek/deepseek-v4-flash") or
217
+ # the hash form `{ "model" =>, "provider"? => }` — because the two already coexist in
218
218
  # this config (`proposer` is a bare ref, `judges` are hashes) and refusing one of
219
219
  # them would only teach operators which page they were reading.
220
220
  def refs_for(config, utility_model)
@@ -248,7 +248,7 @@ module Insika
248
248
  # is actually configured.
249
249
  #
250
250
  # Returns the MESSAGE, not `.content`: the token counts ride on it and the
251
- # budget (§3.9) is what spends them. `Proposer` reads either shape.
251
+ # budget is what spends them. `Proposer` reads either shape.
252
252
  def ruby_llm_ask(model, provider, llm: nil)
253
253
  require "ruby_llm"
254
254
  llm ||= RubyLLM
@@ -4,7 +4,7 @@ require "securerandom"
4
4
  require "time"
5
5
 
6
6
  module Insika
7
- # REFINEMENT DOMAIN store (RFC-0013, phase A). One record per refinement RUN:
7
+ # REFINEMENT DOMAIN store. One record per refinement RUN:
8
8
  # the window that was read, the ranked findings the EvidenceCollector produced,
9
9
  # and the run's outcome. RUNTIME data (it is derived from sessions/tasks/traces),
10
10
  # so it takes the raw `store:` like SessionStore/TaskStore — not the ConfigStore.
@@ -15,8 +15,8 @@ module Insika
15
15
  # Store contract orders lexicographically) and `latest_for` is its last element.
16
16
  # An agent id containing ":" would break that split, so it is rejected on write.
17
17
  #
18
- # Phase A wrote no edits anywhere — a Run was a REPORT and every non-collecting
19
- # status was terminal. Phase C (RFC-0013 §3.2) adds the rest of the lifecycle on
18
+ # wrote no edits anywhere — a Run was a REPORT and every non-collecting
19
+ # status was terminal. adds the rest of the lifecycle on
20
20
  # the SAME record, additively: a gated candidate and the operator's decision.
21
21
  #
22
22
  # collecting ─▶ completed ─▶ gating ─▶ awaiting_approval ─▶ applied
@@ -24,13 +24,13 @@ module Insika
24
24
  # ╰─▶ failed or the operator did)
25
25
  #
26
26
  # **The approval lives here, not in `PendingActionStore`** — a deliberate deviation
27
- # from §3.6. That store is coupled to a suspended TURN: `ApproveAction` resolves
27
+ # from That store is coupled to a suspended TURN: `ApproveAction` resolves
28
28
  # the record and then calls `executor.approve(task_id)` to wake a fiber. A
29
29
  # refinement proposal has no turn and no fiber, so reusing it would mean inventing
30
30
  # a task id, a fake `tool` name, and a wake-up that must never do anything — and
31
31
  # the operator's approvals inbox would fill with rows that are not tool calls. The
32
- # property §3.6 actually wanted is durability across a `kill -9`, and this store
33
- # has had it since phase A.
32
+ # property actually wanted is durability across a `kill -9`, and this store
33
+ # has had it since.
34
34
  class RefinementStore
35
35
  include Coercion
36
36
 
@@ -42,7 +42,7 @@ module Insika
42
42
 
43
43
  # OPEN means "this run will change without anyone asking": work is in flight
44
44
  # (`collecting`, `gating`) or a human owes it an answer (`awaiting_approval`).
45
- # Everything else is terminal — INCLUDING `completed`, which phase A already
45
+ # Everything else is terminal — INCLUDING `completed`, which already
46
46
  # treated that way and which stays true: a report is finished, and gating one is
47
47
  # a new deliberate action, not a continuation. (Widening `terminal?` here is what
48
48
  # the Studio's "latest report" lookup reads, so getting it wrong hides the report.)
@@ -50,7 +50,7 @@ module Insika
50
50
 
51
51
  # `candidate`/`gate` are the WINNER — the one proposal a human is asked about, and
52
52
  # the only thing `ResolveRefinement` ever applies. `candidates` is the whole panel
53
- # (RFC-0013 §3.9, phase D): every candidate that was built, who wrote it, and how
53
+ # every candidate that was built, who wrote it, and how
54
54
  # it scored, including the ones that lost and the ones the budget never gated. A
55
55
  # phase-C run recorded one candidate and no panel; it still reads back correctly,
56
56
  # because a panel of one is the same shape.
@@ -118,7 +118,7 @@ module Insika
118
118
  end
119
119
  end
120
120
 
121
- # -- phase C: the proposal's lifecycle -------------------------------
121
+ # the proposal's lifecycle -------------------------------
122
122
 
123
123
  # Attaches the candidate(s) under gate and moves the run to :gating. Only a
124
124
  # `completed` run can be gated: a report with no findings has nothing to propose
@@ -145,15 +145,15 @@ module Insika
145
145
  end
146
146
 
147
147
  # Records the gate's verdict. A PASS parks the run at :awaiting_approval — a
148
- # human still has to say yes, which is the product and not a formality (D2). A
148
+ # human still has to say yes, which is the product and not a formality. A
149
149
  # FAIL is terminal as :rejected, with the gate report as the stated reason: the
150
150
  # same finding must re-surface with new evidence before anything is proposed
151
- # again, so there is no silent retry loop (§3.6).
151
+ # again, so there is no silent retry loop.
152
152
  #
153
153
  # `report` is the WINNER's (or, when nothing survived, the most informative
154
154
  # refusal). `panel` is every scored entry — the store attaches it as-is and picks
155
155
  # the winning candidate out of it by id. Which candidate WON is the caller's
156
- # ranking decision (§3.5); this store does not rank, it records. -> Run.
156
+ # ranking decision; this store does not rank, it records. -> Run.
157
157
  def gated(id, report:, panel: nil, cost: nil)
158
158
  update(id) do |record|
159
159
  unless record["status"] == "gating"
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # The reliability policy for the turn's provider interaction (WS3): retries
5
+ # with exponential backoff, mid-turn ROTATION across the fallback chain, and
6
+ # the circuit breaker — all as DATA on `AgentProfile#reliability`, never a
7
+ # parallel code path. The failure classification is B9's
8
+ # (ProviderErrorClassifier): ONLY :retryable / :rate_limited_* ever retry or
9
+ # rotate; a :fatal is re-raised immediately (a poisoned credential must not
10
+ # hammer N models).
11
+ #
12
+ # Each ATTEMPT is a fresh chat built by the caller (the coordinator yields the
13
+ # selection): a failed `ask` leaves its user message in the chat, so re-asking
14
+ # the same chat would double the input. The customer-visible answer comes only
15
+ # from the attempt that returns; the fragments of failed attempts ride
16
+ # :intermediate (operator-side) and die with the turn.
17
+ #
18
+ # The breaker is per (tenant, provider/model): a node whose circuit is open is
19
+ # skipped (fail-fast with CircuitOpenError when the PRIMARY is open — the turn
20
+ # dies in ms, no provider call); a node that tripped mid-turn moves on.
21
+ class Reliability
22
+ DEFAULT_TIMEOUT = 30 # seconds per attempt (reliability["timeout"])
23
+
24
+ # circuit_store: CircuitState (the breaker's durable cells).
25
+ # event_stream: where the reliability events go (:provider_fallback, ...).
26
+ # sleeper: ->(seconds) — the backoff wait; injectable for specs.
27
+ def initialize(circuit_store:, event_stream:, sleeper: nil)
28
+ @circuit_store = circuit_store
29
+ @event_stream = event_stream
30
+ @sleeper = sleeper || method(:backoff_wait)
31
+ end
32
+
33
+ # policy: the profile's reliability data (string keys) — the caller skips
34
+ # this coordinator entirely when nil (parity).
35
+ # tenant: the command tenant (breaker scoping; nil = platform).
36
+ # agent: the agent id (event attribution — WS6 alerts read it).
37
+ # selection: the resolved primary ModelSelection.
38
+ # chain: [{ model:, provider: }] fallback candidates (profile's first,
39
+ # then the platform's resolved fallbacks).
40
+ # attempt: ->(selection, attempt_index) { response } — build the chat for
41
+ # that selection and ask. May RAISE a provider-family error.
42
+ #
43
+ # -> the successful response. Raises CircuitOpenError (primary open),
44
+ # or the last retryable error when every node exhausted its retries.
45
+ # NOTHING about a run is stored on `self`: one Reliability instance serves
46
+ # every concurrent turn, and `ask` is a suspension point — an ivar written
47
+ # here would be read back after another fiber's turn overwrote it, and the
48
+ # WS6 alert would name the wrong agent (and, through it, the wrong tenant).
49
+ # The run's identity rides the stack.
50
+ def call(policy:, tenant:, agent: nil, selection:, chain:, &attempt)
51
+ nodes = ([selection] + Array(chain)).map { |node| { selection: node, tries: 0 } }
52
+ retries = [policy["retries"].to_i, 0].max
53
+ breaker = breaker_config(policy)
54
+ # The per-attempt ceiling. A policy WITHOUT a timeout is DEFAULT_TIMEOUT —
55
+ # nothing config overrides here (the old [.., 1].max silently made every
56
+ # unset profile die in ~1s, WS3).
57
+ configured_timeout = policy["timeout"].to_i
58
+ timeout = configured_timeout.positive? ? configured_timeout : DEFAULT_TIMEOUT
59
+ # declaraed HERE (not inside a block) so the post-loop `raise` sees the
60
+ # method-local binding — a first assignment inside a block would not leak.
61
+ last_error = nil
62
+
63
+ # Fail-fast BEFORE any provider call: the PRIMARY's circuit open means
64
+ # this provider family is known-dead — the turn dies in ms.
65
+ if breaker && breaker_open?(tenant, selection, breaker)
66
+ raise circuit_open(tenant, selection, breaker)
67
+ end
68
+ # the last node we actually ASKED: the `from` of a rotation. A node the
69
+ # breaker skipped was never asked, so it is never the `from`.
70
+ asked = nil
71
+ nodes.each do |node|
72
+ selection = node[:selection]
73
+ next if breaker && breaker_open?(tenant, selection, breaker)
74
+
75
+ # ROTATION is an EVENT, not an inference from the usage attribution
76
+ # (WS3): the trace names the node we left, the node we moved to and why.
77
+ # Emitted for every node past the first one asked — with or WITHOUT a
78
+ # breaker (a fallback policy with no circuit_breaker used to rotate in
79
+ # complete silence).
80
+ emit_fallback(agent, asked, selection, last_error) if asked
81
+ asked = selection
82
+
83
+ attempts = retries + 1
84
+ attempts.times do |index|
85
+ node[:tries] += 1
86
+ begin
87
+ response = with_attempt_timeout(timeout) { yield selection, node[:tries] }
88
+ @circuit_store.record_success(tenant: tenant, ref: ref_of(selection)) if breaker
89
+ return response
90
+ rescue StandardError => e
91
+ last_error = e
92
+ # A :fatal provider error — or ANYTHING that is not a provider
93
+ # failure at all (a bug, a domain error, a guardrail raise) — is
94
+ # never retried, never rotated (B9's structural rule). Only
95
+ # retryable/rate-limited (and the per-attempt timeout we raised)
96
+ # spend the retry budget. The B9 classifier is class-name based, so
97
+ # OUR TimeoutError reads as :fatal — the retryable_failure? check
98
+ # (which owns the reliability-stage timeout) must decide FIRST, or
99
+ # the :fatal guard would swallow it (WS3: a timeout never retried,
100
+ # never rotated).
101
+ retryable = retryable_failure?(e)
102
+ raise unless retryable
103
+ raise if kind_of(e) == :fatal && !e.is_a?(Insika::TimeoutError)
104
+
105
+ record_failure(tenant, selection, breaker, e, agent)
106
+ # the last attempt of the last node re-raises; otherwise back off
107
+ # and give the next attempt/node a turn.
108
+ if index < attempts - 1 || node != nodes.last
109
+ @sleeper.call(backoff_seconds(policy, index))
110
+ end
111
+ end
112
+ end
113
+ end
114
+ raise last_error if last_error
115
+
116
+ raise Insika::Error, "reliability loop exhausted without a result"
117
+ end
118
+
119
+ private
120
+
121
+ def breaker_config(policy)
122
+ b = policy["circuit_breaker"]
123
+ return nil unless b.is_a?(Hash) && b["after"].to_i.positive?
124
+
125
+ { after: b["after"].to_i, within: b["within"].to_i, cooldown: b["cooldown"].to_i }
126
+ end
127
+
128
+ # The breaker cell reads need the POLICY's numbers; they ride as args. Only
129
+ # :open FAIL-FASTS; :half_open (cooldown elapsed) is the TRIAL — allowed.
130
+ def breaker_open?(tenant, selection, breaker)
131
+ @circuit_store.state(tenant: tenant, ref: ref_of(selection),
132
+ after: breaker[:after], within: breaker[:within],
133
+ cooldown: breaker[:cooldown]) == :open
134
+ end
135
+
136
+ def circuit_open(tenant, selection, breaker)
137
+ Insika::CircuitOpenError.new(
138
+ "circuit open for #{ref_of(selection)}",
139
+ ref: ref_of(selection),
140
+ retry_after: @circuit_store.retry_after(tenant: tenant, ref: ref_of(selection),
141
+ cooldown: breaker[:cooldown])
142
+ )
143
+ end
144
+
145
+ # The failure is ALWAYS an event; only the circuit bump needs a breaker (the
146
+ # old `return unless breaker` made a retry/fallback policy without a
147
+ # circuit_breaker run with no trace at all).
148
+ def record_failure(tenant, selection, breaker, error, agent)
149
+ emit(:provider_failure,
150
+ { agent: agent, ref: ref_of(selection), error: error.class.name, kind: kind_of(error) })
151
+ return unless breaker
152
+
153
+ tripped = @circuit_store.record_failure(
154
+ tenant: tenant, ref: ref_of(selection),
155
+ after: breaker[:after], within: breaker[:within]
156
+ )
157
+ # the failure that TRIPPED the circuit is itself an alert (WS6).
158
+ emit(:breaker_open, { agent: agent, ref: ref_of(selection), tenant: tenant }) if tripped == :open
159
+ end
160
+
161
+ # The mid-turn rotation across the fallback chain: the node we left, the one
162
+ # we moved to, and the error that spent the previous node's retries.
163
+ def emit_fallback(agent, from, to, error)
164
+ emit(:provider_fallback,
165
+ { agent: agent, from: ref_of(from), to: ref_of(to),
166
+ error: error&.class&.name, kind: error && kind_of(error) })
167
+ end
168
+
169
+ def kind_of(error) = ProviderErrorClassifier.classify(error).kind
170
+
171
+ # A provider-family error OR the per-attempt timeout: both are transient
172
+ # transport-class failures that spend the retry budget.
173
+ def retryable_failure?(error)
174
+ ProviderErrorClassifier.provider_error?(error) ||
175
+ (error.is_a?(Insika::TimeoutError) && error.stage.to_s == "reliability")
176
+ end
177
+
178
+ def with_attempt_timeout(timeout, &blk)
179
+ return yield unless Async::Task.current?
180
+ Async::Task.current.with_timeout(timeout) { yield }
181
+ rescue Async::TimeoutError
182
+ # a per-attempt timeout is a TRANSPORT-class failure: counted, retried.
183
+ raise Insika::TimeoutError.new("provider attempt exceeded #{timeout}s", stage: :reliability)
184
+ end
185
+
186
+ # The breaker cell id: "provider/model" for the ref'd node — a ModelSelection
187
+ # (primary) or a { model:, provider: } hash (fallback node).
188
+ def ref_of(selection)
189
+ model = selection.respond_to?(:model) ? selection.model.to_s : selection[:model].to_s
190
+ provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
191
+ provider ? "#{provider}/#{model}" : model
192
+ end
193
+
194
+ def backoff_seconds(policy, index)
195
+ case policy["backoff"].to_s
196
+ when "exponential" then 2**index
197
+ else index + 1
198
+ end
199
+ end
200
+
201
+ def backoff_wait(seconds)
202
+ Async::Task.current? ? Async::Task.current.sleep(seconds) : Kernel.sleep(seconds)
203
+ end
204
+
205
+ def emit(type, data)
206
+ @event_stream.emit(Insika::Event.new(
207
+ type: type, data: data, meta: { at: Time.now.utc.iso8601 }
208
+ ))
209
+ end
210
+ end
211
+ end