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,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # REFINEMENT DOMAIN store. One record per refinement RUN:
8
+ # the window that was read, the ranked findings the EvidenceCollector produced,
9
+ # and the run's outcome. RUNTIME data (it is derived from sessions/tasks/traces),
10
+ # so it takes the raw `store:` like SessionStore/TaskStore — not the ConfigStore.
11
+ #
12
+ # The key embeds the agent and the start timestamp:
13
+ # "run:<agent_id>:<started_at>:<id>"
14
+ # so `list(SCOPE, "run:<agent>:")` comes back CHRONOLOGICAL for that agent (the
15
+ # Store contract orders lexicographically) and `latest_for` is its last element.
16
+ # An agent id containing ":" would break that split, so it is rejected on write.
17
+ #
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
+ # the SAME record, additively: a gated candidate and the operator's decision.
21
+ #
22
+ # collecting ─▶ completed ─▶ gating ─▶ awaiting_approval ─▶ applied
23
+ # ╰─▶ no_findings ╰─▶ rejected (the gate failed it,
24
+ # ╰─▶ failed or the operator did)
25
+ #
26
+ # **The approval lives here, not in `PendingActionStore`** — a deliberate deviation
27
+ # from That store is coupled to a suspended TURN: `ApproveAction` resolves
28
+ # the record and then calls `executor.approve(task_id)` to wake a fiber. A
29
+ # refinement proposal has no turn and no fiber, so reusing it would mean inventing
30
+ # a task id, a fake `tool` name, and a wake-up that must never do anything — and
31
+ # the operator's approvals inbox would fill with rows that are not tool calls. The
32
+ # property actually wanted is durability across a `kill -9`, and this store
33
+ # has had it since.
34
+ class RefinementStore
35
+ include Coercion
36
+
37
+ SCOPE = "refinements"
38
+ KEY_PREFIX = "run:"
39
+
40
+ STATUSES = %i[collecting completed gating awaiting_approval applied rejected
41
+ no_findings failed].freeze
42
+
43
+ # OPEN means "this run will change without anyone asking": work is in flight
44
+ # (`collecting`, `gating`) or a human owes it an answer (`awaiting_approval`).
45
+ # Everything else is terminal — INCLUDING `completed`, which already
46
+ # treated that way and which stays true: a report is finished, and gating one is
47
+ # a new deliberate action, not a continuation. (Widening `terminal?` here is what
48
+ # the Studio's "latest report" lookup reads, so getting it wrong hides the report.)
49
+ OPEN = %i[collecting gating awaiting_approval].freeze
50
+
51
+ # `candidate`/`gate` are the WINNER — the one proposal a human is asked about, and
52
+ # the only thing `ResolveRefinement` ever applies. `candidates` is the whole panel
53
+ # every candidate that was built, who wrote it, and how
54
+ # it scored, including the ones that lost and the ones the budget never gated. A
55
+ # phase-C run recorded one candidate and no panel; it still reads back correctly,
56
+ # because a panel of one is the same shape.
57
+ Run = Data.define(:id, :agent_id, :status, :window, :findings, :excluded,
58
+ :started_at, :finished_at, :error,
59
+ :candidate, :candidates, :gate, :cost, :decision) do
60
+ def terminal? = !OPEN.include?(status)
61
+ def findings_count = findings.size
62
+
63
+ # Is there a gated proposal waiting for a human? The one question the Studio
64
+ # and the apply command both ask.
65
+ def awaiting_approval? = status == :awaiting_approval
66
+ def gate_passed? = gate.is_a?(Hash) && gate["passed"] == true
67
+ def edits = (candidate || {})["edits"] || []
68
+ def panel = Array(candidates)
69
+ end
70
+
71
+ def initialize(store:)
72
+ @store = store
73
+ end
74
+
75
+ # Opens a run (:collecting). `window` is the collector's window as data
76
+ # ({ "last_sessions" => N } | { "since" => iso8601 }) — recorded so a report can
77
+ # be read months later and still say what it looked at. -> Run.
78
+ def create(agent_id:, window: {}, id: SecureRandom.uuid, at: nil)
79
+ agent = agent_id.to_s
80
+ raise Insika::ValidationError, "agent_id is required" if agent.empty?
81
+ raise Insika::ValidationError, "agent_id must not contain ':'" if agent.include?(":")
82
+
83
+ started = at || timestamp
84
+ record = {
85
+ "id" => id.to_s, "agent_id" => agent, "status" => "collecting",
86
+ "window" => deep_stringify(window || {}), "findings" => [], "excluded" => 0,
87
+ "started_at" => started, "finished_at" => nil, "error" => nil,
88
+ "candidate" => nil, "candidates" => [], "gate" => nil, "cost" => nil,
89
+ "decision" => nil
90
+ }
91
+ @store.set(SCOPE, key_for(agent, started, id), record)
92
+ to_run(record)
93
+ end
94
+
95
+ # Closes a run with its findings. Empty findings -> :no_findings (a distinct
96
+ # outcome from :completed — "we looked and it was clean" is a real answer, not a
97
+ # failure). -> Run. ArgumentError if the run is already terminal.
98
+ # `excluded` is how many turns the window dropped on purpose (synthetic
99
+ # traffic) — recorded so a report never reads cleaner than the data was.
100
+ def complete(id, findings:, excluded: 0)
101
+ update(id) do |record|
102
+ guard_open!(record)
103
+ list = Array(findings).map { |f| deep_stringify(f.respond_to?(:to_h) ? f.to_h : f) }
104
+ record["findings"] = list
105
+ record["excluded"] = Integer(excluded)
106
+ record["status"] = list.empty? ? "no_findings" : "completed"
107
+ record["finished_at"] = timestamp
108
+ end
109
+ end
110
+
111
+ # Closes a run as :failed, recording the error. -> Run.
112
+ def fail(id, error:)
113
+ update(id) do |record|
114
+ guard_open!(record)
115
+ record["status"] = "failed"
116
+ record["error"] = error.to_s
117
+ record["finished_at"] = timestamp
118
+ end
119
+ end
120
+
121
+ # the proposal's lifecycle -------------------------------
122
+
123
+ # Attaches the candidate(s) under gate and moves the run to :gating. Only a
124
+ # `completed` run can be gated: a report with no findings has nothing to propose
125
+ # from, and a failed one never finished looking.
126
+ #
127
+ # `candidate:` (one) and `candidates:` (a panel) are the same call — the panel is
128
+ # recorded before the gate runs so the Studio can show WHAT is being scored while
129
+ # it is being scored, which is minutes of real replay. No winner is claimed yet:
130
+ # `candidate` stays nil until `gated` says which one it is. -> Run.
131
+ def gating(id, candidate: nil, candidates: nil)
132
+ panel = Array(candidates || [candidate].compact)
133
+ raise Insika::ValidationError, "a candidate is required to gate" if panel.empty?
134
+
135
+ update(id) do |record|
136
+ unless record["status"] == "completed"
137
+ raise ArgumentError, "run #{record['id']} is #{record['status']}, expected completed"
138
+ end
139
+
140
+ record["candidates"] = panel.map { |c| entry_for(c) }
141
+ record["candidate"] = nil
142
+ record["gate"] = nil
143
+ record["status"] = "gating"
144
+ end
145
+ end
146
+
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. A
149
+ # FAIL is terminal as :rejected, with the gate report as the stated reason: the
150
+ # same finding must re-surface with new evidence before anything is proposed
151
+ # again, so there is no silent retry loop.
152
+ #
153
+ # `report` is the WINNER's (or, when nothing survived, the most informative
154
+ # refusal). `panel` is every scored entry — the store attaches it as-is and picks
155
+ # the winning candidate out of it by id. Which candidate WON is the caller's
156
+ # ranking decision; this store does not rank, it records. -> Run.
157
+ def gated(id, report:, panel: nil, cost: nil)
158
+ update(id) do |record|
159
+ unless record["status"] == "gating"
160
+ raise ArgumentError, "run #{record['id']} is #{record['status']}, expected gating"
161
+ end
162
+
163
+ gate = deep_stringify(report.respond_to?(:to_h) ? report.to_h : report)
164
+ record["candidates"] = panel.map { |e| entry_for(e) } if panel
165
+ record["candidate"] = winning_candidate(record, gate)
166
+ record["gate"] = gate
167
+ record["cost"] = deep_stringify(cost.respond_to?(:to_h) ? cost.to_h : cost) if cost
168
+ if gate["passed"]
169
+ record["status"] = "awaiting_approval"
170
+ else
171
+ record["status"] = "rejected"
172
+ record["decision"] = { "by" => "gate", "at" => timestamp, "note" => gate["reason"] }
173
+ record["finished_at"] = timestamp
174
+ end
175
+ end
176
+ end
177
+
178
+ # The operator's answer to a gated proposal. `applied` is recorded only after the
179
+ # writes land, so a crash between the two leaves the run awaiting approval and
180
+ # the operator re-approves — replaying a write that is already versioned and
181
+ # idempotent-ish beats recording a lie. -> Run.
182
+ def resolve(id, decision:, operator: nil, note: nil)
183
+ target = decision.to_sym
184
+ unless %i[applied rejected].include?(target)
185
+ raise Insika::ValidationError, "invalid decision: #{decision} (applied|rejected)"
186
+ end
187
+
188
+ update(id) do |record|
189
+ unless record["status"] == "awaiting_approval"
190
+ raise ArgumentError, "run #{record['id']} is #{record['status']}, expected awaiting_approval"
191
+ end
192
+
193
+ record["status"] = target.to_s
194
+ record["decision"] = { "by" => (Coercion.presence(operator) || "operator").to_s,
195
+ "at" => timestamp, "note" => Coercion.presence(note) }.compact
196
+ record["finished_at"] = timestamp
197
+ end
198
+ end
199
+
200
+ # -> [Run] every run parked on a human, most recent first. What the Studio badges.
201
+ def awaiting_approval(limit: 20)
202
+ recent(limit: 200).select(&:awaiting_approval?).first(limit)
203
+ end
204
+
205
+ # -> Run | nil. O(n) scan over the scope (the key carries agent+timestamp, so
206
+ # there is no index by id): one node, local SQLite, runs are operator-paced.
207
+ def find(id)
208
+ key = key_for_id(id)
209
+ key && to_run(@store.get(SCOPE, key))
210
+ end
211
+
212
+ # -> [Run] for one agent, MOST RECENT FIRST, capped by `limit`.
213
+ def for_agent(agent_id, limit: nil)
214
+ keys = @store.list(SCOPE, "#{KEY_PREFIX}#{agent_id}:").reverse
215
+ keys = keys.first(limit) if limit
216
+ keys.filter_map { |k| to_run(@store.get(SCOPE, k)) }
217
+ end
218
+
219
+ # -> Run | nil (the agent's most recent run, whatever its status).
220
+ def latest_for(agent_id) = for_agent(agent_id, limit: 1).first
221
+
222
+ # -> [Run] across every agent, most recent first, capped.
223
+ def recent(limit: 20)
224
+ @store.list(SCOPE, KEY_PREFIX)
225
+ .filter_map { |k| to_run(@store.get(SCOPE, k)) }
226
+ .sort_by { |r| r.started_at.to_s }.reverse.first(limit)
227
+ end
228
+
229
+ private
230
+
231
+ # One panel row: `{ candidate:, proposers: [], gate: }`. A bare Candidate (phase
232
+ # C, an operator's own payload, an older record) is wrapped into the same shape,
233
+ # so every reader has ONE thing to read and the two eras of this record do not
234
+ # each need a branch in the Studio.
235
+ def entry_for(value)
236
+ raw = deep_stringify(value.respond_to?(:to_h) ? value.to_h : value)
237
+ return raw if raw.key?("candidate")
238
+
239
+ { "candidate" => raw, "proposers" => [raw["proposer"]].compact, "gate" => nil }
240
+ end
241
+
242
+ # The candidate the winning report belongs to, matched by id. A report whose
243
+ # candidate is not in the panel (an older record, a hand-built payload) leaves
244
+ # whatever was already there rather than inventing a winner.
245
+ def winning_candidate(record, gate)
246
+ match = Array(record["candidates"]).find do |entry|
247
+ (entry["candidate"] || {})["id"] == gate["candidate_id"]
248
+ end
249
+ match ? match["candidate"] : record["candidate"]
250
+ end
251
+
252
+ def key_for(agent, started_at, id) = "#{KEY_PREFIX}#{agent}:#{started_at}:#{id}"
253
+
254
+ # The id is the key's last segment; scanning is the price of keeping the key
255
+ # chronological (which is what every read except `find` actually wants).
256
+ def key_for_id(id)
257
+ suffix = ":#{id}"
258
+ @store.list(SCOPE, KEY_PREFIX).find { |k| k.end_with?(suffix) }
259
+ end
260
+
261
+ def update(id)
262
+ key = key_for_id(id)
263
+ raise Insika::NotFoundError, "refinement run not found: #{id}" if key.nil?
264
+
265
+ record = @store.get(SCOPE, key)
266
+ raise Insika::NotFoundError, "refinement run not found: #{id}" if record.nil?
267
+
268
+ yield record
269
+ @store.set(SCOPE, key, record)
270
+ to_run(record)
271
+ end
272
+
273
+ def guard_open!(record)
274
+ return if record["status"] == "collecting"
275
+
276
+ raise ArgumentError, "run #{record['id']} is already #{record['status']}"
277
+ end
278
+
279
+ def to_run(record)
280
+ return nil if record.nil?
281
+
282
+ Run.new(
283
+ id: record["id"], agent_id: record["agent_id"],
284
+ status: record["status"].to_sym, window: record["window"] || {},
285
+ findings: record["findings"] || [], excluded: record["excluded"] || 0,
286
+ started_at: record["started_at"], finished_at: record["finished_at"],
287
+ error: record["error"],
288
+ candidate: record["candidate"], candidates: record["candidates"] || [],
289
+ gate: record["gate"], cost: record["cost"], decision: record["decision"]
290
+ )
291
+ end
292
+
293
+ def timestamp = Time.now.utc.iso8601
294
+ end
295
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Generic executable base: Registry =
5
+ # EXECUTABLE content (tools/workflows/policies). Catalog (skills/prompts) is
6
+ # non-executable and does not inherit from here.
7
+ #
8
+ # Immutable post-boot by CONSTRUCTION (only boot registers): there
9
+ # is no `freeze!`; immutability is not enforced at runtime.
10
+ class Registry
11
+ Entry = Data.define(:name, :plugin, :metadata, :factory)
12
+
13
+ def initialize
14
+ @entries = {}
15
+ end
16
+
17
+ # factory = block OR the positional callable. metadata captured by **kw
18
+ # (Symbol keys, stored as-is). Duplicate: FIRST wins (plugin precedence)
19
+ # — the second is discarded with a warn, never overwritten.
20
+ def register(name, callable = nil, plugin: nil, **metadata, &block)
21
+ name = name.to_s
22
+ factory = block || (callable.nil? ? nil : -> { callable })
23
+ raise ArgumentError, "registration without factory: #{name}" if factory.nil?
24
+
25
+ if @entries.key?(name)
26
+ existing = @entries[name]
27
+ warn "[registry] '#{name}' already registered by #{existing.plugin.inspect}; " \
28
+ "descartando registro de #{plugin.inspect} (primeiro vence)"
29
+ return self
30
+ end
31
+
32
+ @entries[name] = Entry.new(name: name, plugin: plugin&.to_s, metadata: metadata, factory: factory)
33
+ self
34
+ end
35
+
36
+ # -> instance (factory.call) | raise NotFoundError.
37
+ def resolve(name)
38
+ entry(name).factory.call
39
+ end
40
+
41
+ # -> the raw Entry (name/plugin/metadata/factory) | raise NotFoundError. For
42
+ # readers that need the metadata WITHOUT resolving the factory (discovery,
43
+ # WorkflowRegistry#definition).
44
+ def entry(name)
45
+ @entries[name.to_s] ||
46
+ (raise Insika::NotFoundError, "'#{name}' not registered in #{self.class}")
47
+ end
48
+
49
+ def entries = @entries.values
50
+ def names = @entries.keys
51
+
52
+ # Loader rollback support: removes the entries of a
53
+ # plugin. NOT a runtime API (registries are immutable post-boot).
54
+ def deregister_plugin(plugin_id)
55
+ @entries.delete_if { |_name, entry| entry.plugin == plugin_id.to_s }
56
+ nil
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,185 @@
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
+ def call(policy:, tenant:, agent: nil, selection:, chain:, &attempt)
46
+ @agent = agent # event attribution (WF6 alerts) for THIS run
47
+ nodes = ([selection] + Array(chain)).map { |node| { selection: node, tries: 0 } }
48
+ retries = [policy["retries"].to_i, 0].max
49
+ breaker = breaker_config(policy)
50
+ # The per-attempt ceiling. A policy WITHOUT a timeout is DEFAULT_TIMEOUT —
51
+ # nothing config overrides here (the old [.., 1].max silently made every
52
+ # unset profile die in ~1s, WS3).
53
+ configured_timeout = policy["timeout"].to_i
54
+ timeout = configured_timeout.positive? ? configured_timeout : DEFAULT_TIMEOUT
55
+ # declaraed HERE (not inside a block) so the post-loop `raise` sees the
56
+ # method-local binding — a first assignment inside a block would not leak.
57
+ last_error = nil
58
+
59
+ # Fail-fast BEFORE any provider call: the PRIMARY's circuit open means
60
+ # this provider family is known-dead — the turn dies in ms.
61
+ if breaker && breaker_open?(tenant, selection, breaker)
62
+ raise circuit_open(tenant, selection, breaker)
63
+ end
64
+ nodes.each do |node|
65
+ selection = node[:selection]
66
+ next if breaker && breaker_open?(tenant, selection, breaker)
67
+
68
+ attempts = retries + 1
69
+ attempts.times do |index|
70
+ node[:tries] += 1
71
+ begin
72
+ response = with_attempt_timeout(timeout) { yield selection, node[:tries] }
73
+ @circuit_store.record_success(tenant: tenant, ref: ref_of(selection)) if breaker
74
+ return response
75
+ rescue StandardError => e
76
+ last_error = e
77
+ # A :fatal provider error — or ANYTHING that is not a provider
78
+ # failure at all (a bug, a domain error, a guardrail raise) — is
79
+ # never retried, never rotated (B9's structural rule). Only
80
+ # retryable/rate-limited (and the per-attempt timeout we raised)
81
+ # spend the retry budget. The B9 classifier is class-name based, so
82
+ # OUR TimeoutError reads as :fatal — the retryable_failure? check
83
+ # (which owns the reliability-stage timeout) must decide FIRST, or
84
+ # the :fatal guard would swallow it (WS3: a timeout never retried,
85
+ # never rotated).
86
+ retryable = retryable_failure?(e)
87
+ raise unless retryable
88
+ raise if kind_of(e) == :fatal && !e.is_a?(Insika::TimeoutError)
89
+
90
+ record_failure(tenant, selection, breaker, e)
91
+ # the last attempt of the last node re-raises; otherwise back off
92
+ # and give the next attempt/node a turn.
93
+ if index < attempts - 1 || node != nodes.last
94
+ @sleeper.call(backoff_seconds(policy, index))
95
+ end
96
+ end
97
+ end
98
+ end
99
+ raise last_error if last_error
100
+
101
+ raise Insika::Error, "reliability loop exhausted without a result"
102
+ end
103
+
104
+ private
105
+
106
+ def breaker_config(policy)
107
+ b = policy["circuit_breaker"]
108
+ return nil unless b.is_a?(Hash) && b["after"].to_i.positive?
109
+
110
+ { after: b["after"].to_i, within: b["within"].to_i, cooldown: b["cooldown"].to_i }
111
+ end
112
+
113
+ # The breaker cell reads need the POLICY's numbers; they ride as args. Only
114
+ # :open FAIL-FASTS; :half_open (cooldown elapsed) is the TRIAL — allowed.
115
+ def breaker_open?(tenant, selection, breaker)
116
+ @circuit_store.state(tenant: tenant, ref: ref_of(selection),
117
+ after: breaker[:after], within: breaker[:within],
118
+ cooldown: breaker[:cooldown]) == :open
119
+ end
120
+
121
+ def circuit_open(tenant, selection, breaker)
122
+ Insika::CircuitOpenError.new(
123
+ "circuit open for #{ref_of(selection)}",
124
+ ref: ref_of(selection),
125
+ retry_after: @circuit_store.retry_after(tenant: tenant, ref: ref_of(selection),
126
+ cooldown: breaker[:cooldown])
127
+ )
128
+ end
129
+
130
+ def record_failure(tenant, selection, breaker, error)
131
+ return unless breaker
132
+
133
+ tripped = @circuit_store.record_failure(
134
+ tenant: tenant, ref: ref_of(selection),
135
+ after: breaker[:after], within: breaker[:within]
136
+ )
137
+ emit(:provider_failure,
138
+ { agent: @agent, ref: ref_of(selection), error: error.class.name, kind: kind_of(error) })
139
+ # the failure that TRIPPED the circuit is itself an alert (WS6).
140
+ emit(:breaker_open, { agent: @agent, ref: ref_of(selection), tenant: tenant }) if tripped == :open
141
+ end
142
+
143
+ def kind_of(error) = ProviderErrorClassifier.classify(error).kind
144
+
145
+ # A provider-family error OR the per-attempt timeout: both are transient
146
+ # transport-class failures that spend the retry budget.
147
+ def retryable_failure?(error)
148
+ ProviderErrorClassifier.provider_error?(error) ||
149
+ (error.is_a?(Insika::TimeoutError) && error.stage.to_s == "reliability")
150
+ end
151
+
152
+ def with_attempt_timeout(timeout, &blk)
153
+ return yield unless Async::Task.current?
154
+ Async::Task.current.with_timeout(timeout) { yield }
155
+ rescue Async::TimeoutError
156
+ # a per-attempt timeout is a TRANSPORT-class failure: counted, retried.
157
+ raise Insika::TimeoutError.new("provider attempt exceeded #{timeout}s", stage: :reliability)
158
+ end
159
+
160
+ # The breaker cell id: "provider/model" for the ref'd node — a ModelSelection
161
+ # (primary) or a { model:, provider: } hash (fallback node).
162
+ def ref_of(selection)
163
+ model = selection.respond_to?(:model) ? selection.model.to_s : selection[:model].to_s
164
+ provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
165
+ provider ? "#{provider}/#{model}" : model
166
+ end
167
+
168
+ def backoff_seconds(policy, index)
169
+ case policy["backoff"].to_s
170
+ when "exponential" then 2**index
171
+ else index + 1
172
+ end
173
+ end
174
+
175
+ def backoff_wait(seconds)
176
+ Async::Task.current? ? Async::Task.current.sleep(seconds) : Kernel.sleep(seconds)
177
+ end
178
+
179
+ def emit(type, data)
180
+ @event_stream.emit(Insika::Event.new(
181
+ type: type, data: data, meta: { at: Time.now.utc.iso8601 }
182
+ ))
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Safety
5
+ # Per-agent guardrail configuration, read from
6
+ # `profile.guardrails`. OPT-IN like `capabilities`: an agent that says nothing
7
+ # gets the CONSERVATIVE default — deterministic detectors ON, LLM moderator OFF.
8
+ #
9
+ # Tolerant of the JSON round-trip (string OR symbol keys / values), same
10
+ # discipline as ModelPolicy — the StoredProfileSource persists this as a plain
11
+ # Hash and it comes back stringified.
12
+ #
13
+ # guardrails: {
14
+ # input: true|false, # run the input guardrail middleware (default true)
15
+ # output: true|false, # run the output filter + validator (default true)
16
+ # moderator: "provider/model"|nil, # LLM moderator model; nil = deterministic only
17
+ # strictness: "low"|"medium"|"high", # which input categories fire (default medium)
18
+ # responses: { <category> => "<safe reply>", ... } # per-agent override, see below
19
+ # }
20
+ #
21
+ # `responses` is the CONFIGURATION-OVER-CONVENTION knob. The engine
22
+ # ships neutral built-in refusals (Safety::SafeResponses::DEFAULTS), but this is
23
+ # OSS across arbitrary businesses/languages, so we never hard-bake tone: an agent
24
+ # overrides the safe reply per category (`injection`/`sexual`/`abuse`/`escalate`/
25
+ # …) or sets a single catch-all `default`. Resolution order (SafeResponses.for):
26
+ # agent[category] → agent["default"] → built-in[category] → built-in[:default].
27
+ class Config
28
+ # strictness -> input categories that the deterministic scan runs.
29
+ # low = only injection (+ output redaction) — highest confidence, fewest FPs
30
+ # medium = injection + sexual + abuse (the default)
31
+ # high = same families, reserved for future broader lists
32
+ STRICTNESS_CATEGORIES = {
33
+ low: %i[injection],
34
+ medium: %i[injection sexual abuse],
35
+ high: %i[injection sexual abuse]
36
+ }.freeze
37
+
38
+ DEFAULT_STRICTNESS = :medium
39
+
40
+ attr_reader :input, :output, :moderator, :strictness, :responses
41
+
42
+ def initialize(input:, output:, moderator:, strictness:, responses: {})
43
+ @input = input
44
+ @output = output
45
+ @moderator = moderator
46
+ @strictness = strictness
47
+ @responses = responses # { "category" => "safe reply" }, agent override map
48
+ end
49
+
50
+ # Builds a Config from a profile. A nil/empty `guardrails` -> the
51
+ # conservative default (see from_hash).
52
+ def self.from_profile(profile) = from_hash(profile.guardrails)
53
+
54
+ def self.from_hash(raw)
55
+ h = symbolize(raw)
56
+ new(
57
+ input: bool(h.fetch(:input, true)),
58
+ output: bool(h.fetch(:output, true)),
59
+ moderator: presence(h[:moderator]),
60
+ strictness: normalize_strictness(h[:strictness]),
61
+ responses: normalize_responses(h[:responses])
62
+ )
63
+ end
64
+
65
+ # Input categories the deterministic scan should run, per strictness.
66
+ def input_categories = STRICTNESS_CATEGORIES.fetch(@strictness, STRICTNESS_CATEGORIES[DEFAULT_STRICTNESS])
67
+
68
+ # A guardrail is fully off only when BOTH sides are disabled — cheap early-out.
69
+ def enabled? = @input || @output
70
+
71
+ def moderator? = !@moderator.nil?
72
+
73
+ def self.symbolize(raw)
74
+ return {} unless raw.is_a?(Hash)
75
+
76
+ raw.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
77
+ end
78
+
79
+ def self.bool(v)
80
+ return v if v == true || v == false
81
+ return false if v.nil?
82
+
83
+ s = v.to_s.strip.downcase
84
+ !s.empty? && !%w[false 0 off no].include?(s)
85
+ end
86
+
87
+ def self.presence(v) = Insika::Coercion.presence(v)
88
+
89
+ def self.normalize_strictness(v)
90
+ sym = v.to_s.strip.downcase.to_sym
91
+ STRICTNESS_CATEGORIES.key?(sym) ? sym : DEFAULT_STRICTNESS
92
+ end
93
+
94
+ # Per-agent safe-reply overrides -> { "category" => "text" } with STRING keys
95
+ # (SafeResponses looks up by string), blanks dropped. Tolerant of the JSON
96
+ # round-trip and of a non-Hash (ignored -> {}).
97
+ def self.normalize_responses(v)
98
+ return {} unless v.is_a?(Hash)
99
+
100
+ v.each_with_object({}) do |(k, val), acc|
101
+ text = val.to_s.strip
102
+ acc[k.to_s] = text unless text.empty?
103
+ end
104
+ end
105
+
106
+ private_class_method :symbolize, :bool, :presence, :normalize_strictness, :normalize_responses
107
+ end
108
+ end
109
+ end