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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "ipaddr"
5
+ require "resolv"
6
+
7
+ module Insika
8
+ # EGRESS guard for data-tools (SSRF). A data-tool makes a server-side HTTP
9
+ # request with a URL coming from UI-editable config — without a guard, it's an
10
+ # SSRF vector (hitting cloud metadata, internal services, localhost). Rules
11
+ # (spec):
12
+ # - https only by default (http requires explicit opt-in);
13
+ # - host required;
14
+ # - optional host allowlist (when present, only it passes);
15
+ # - resolves the host and BLOCKS if ANY address falls into a private/
16
+ # loopback/link-local/metadata network (defense against DNS rebinding);
17
+ # - `allow_private:` (opt-in) ALLOWS the private target — to reach a trusted
18
+ # INTERNAL API (the consumer's /api/internal/* comes in via an
19
+ # allowlist). Dangerous without `host_allowlist`: PIN it to a known host.
20
+ # Default false = strict guard.
21
+ #
22
+ # `violation(url, ...)` returns nil (ok) or a String with the reason — the
23
+ # DataDefinedTool turns the reason into `{ error: }` to the model (never raises).
24
+ module EgressGuard
25
+ BLOCKED = [
26
+ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
27
+ "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16",
28
+ "198.18.0.0/15", "::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96"
29
+ ].map { |c| IPAddr.new(c) }.freeze
30
+
31
+ module_function
32
+
33
+ # -> nil (allowed) | String (block reason).
34
+ def violation(url, allow_http: false, host_allowlist: nil, allow_private: false)
35
+ uri = begin
36
+ URI.parse(url.to_s)
37
+ rescue URI::InvalidURIError
38
+ return "invalid URL"
39
+ end
40
+
41
+ return "unsupported scheme" unless %w[http https].include?(uri.scheme)
42
+ return "http not allowed (use https)" if uri.scheme == "http" && !allow_http
43
+
44
+ host = uri.host
45
+ return "missing host" if host.nil? || host.empty?
46
+ return "host not in allowlist" if host_allowlist && !host_allowlist.include?(host)
47
+
48
+ addrs = resolve(host)
49
+ return "host did not resolve" if addrs.empty?
50
+ # allow_private skips the private-network block (trusted internal API,
51
+ # Without it, a private/loopback/metadata target is always blocked.
52
+ return "private-network destination blocked" if !allow_private && addrs.any? { |ip| blocked?(ip) }
53
+
54
+ nil
55
+ end
56
+
57
+ # Literal host (IP) -> itself; hostname -> resolve via DNS. -> [IPAddr].
58
+ def resolve(host)
59
+ literal = ip_or_nil(host.delete_prefix("[").delete_suffix("]"))
60
+ return [literal] if literal
61
+
62
+ Resolv.getaddresses(host).filter_map { |a| ip_or_nil(a) }
63
+ rescue Resolv::ResolvError, SocketError
64
+ []
65
+ end
66
+
67
+ def blocked?(ip) = BLOCKED.any? { |net| net.include?(ip) }
68
+
69
+ def ip_or_nil(str)
70
+ IPAddr.new(str)
71
+ rescue IPAddr::InvalidAddressError
72
+ nil
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # STRICT config, environment layer. OpenClaw's config discipline
5
+ # — "recusa boot com chave desconhecida, no silent config compat" — applied to the
6
+ # env vars the engine reads at boot. A declarative registry of the keys the engine
7
+ # OWNS (config over convention: the schema IS data), used two ways:
8
+ #
9
+ # · `validate(env)` — returns structured Findings: a value that fails its type
10
+ # (INSIKA_PORT=abc), an UNKNOWN key inside a namespace the engine owns
11
+ # (INSIKA_EGRES_ALLOW_HTTP — a typo the runtime would otherwise ignore in
12
+ # silence), and a DEPRECATED legacy key still set under the old HARNESS_ prefix.
13
+ # Unknown-key detection is scoped to the OWNED prefixes only, so the platform's
14
+ # own vars (Railway's RAILWAY_*, PORT, PATH, the litestream sidecar's
15
+ # LITESTREAM_*, the deployment's DEEPSEEK_*/CONSUMER_*) are never flagged.
16
+ # · `enforce!(strict:)` — the boot gate. WARNS on every finding by default and
17
+ # lets the engine come up (last-known-good — a rotated key or a typo must never
18
+ # take the whole service down, same reasoning as the resilient DEEPSEEK boot);
19
+ # RAISES ConfigError only when strictness is on (INSIKA_CONFIG_STRICT truthy,
20
+ # or `strict: true`).
21
+ #
22
+ # RENAME (env pass 2): the engine's owned prefix is now INSIKA_. The old HARNESS_
23
+ # names still work — `reconcile_legacy!` backfills INSIKA_* from any HARNESS_* alias
24
+ # at boot (new name wins), and `read` gives the same dual-read to the self-contained
25
+ # readers (Telemetry, TurnTiming, SubagentGraph) that receive an env hash directly.
26
+ # A legacy name in use surfaces as a `:deprecated` warning (never fatal) so operators
27
+ # get a clear "rename to INSIKA_*" without a broken boot.
28
+ #
29
+ # Insika::Doctor reuses `validate` for its `env:*` checks; the boot roots call
30
+ # `enforce!`. A deployment layers its own keys on via `extra:` (see
31
+ # config/deployment.rb) so `insika env` and `insika doctor` see the full picture.
32
+ module EnvSchema
33
+ module_function
34
+
35
+ # One env key the engine knows about. `type` drives validation; `secret` masks
36
+ # the value in `insika env`; `enum` restricts allowed values; `required` makes
37
+ # a blank value a finding (engine keys are all optional — deployment resilience —
38
+ # so `required` is used by deployment extras, not the defaults here).
39
+ Spec = Data.define(:name, :type, :secret, :required, :enum, :description) do
40
+ def secret? = secret
41
+ def required? = required
42
+
43
+ # nil/blank when optional -> no finding. Present -> must satisfy the type. ->
44
+ # error string or nil.
45
+ def error_for(raw)
46
+ value = raw.to_s
47
+ return "is required (unset)" if value.strip.empty? && required
48
+ return nil if value.strip.empty?
49
+
50
+ case type
51
+ when :integer
52
+ "must be an integer, got #{value.inspect}" unless value.match?(/\A-?\d+\z/)
53
+ when :boolean
54
+ "must be a boolean (#{BOOLEANS.join('/')}), got #{value.inspect}" unless EnvSchema.boolean?(value)
55
+ when :enum
56
+ "must be one of #{enum.inspect}, got #{value.inspect}" unless Array(enum).include?(value)
57
+ when :url
58
+ "must be an http(s) URL, got #{value.inspect}" unless value.match?(%r{\Ahttps?://\S+\z})
59
+ end # :string / :csv / :path -> any string is valid
60
+ end
61
+ end
62
+
63
+ # A problem (or an :ok note) about the environment. dry/serializable — the proc-
64
+ # free shape Doctor and `insika env` both render.
65
+ Finding = Data.define(:key, :kind, :severity, :message) do
66
+ def to_h = { "key" => key, "kind" => kind.to_s, "severity" => severity.to_s, "message" => message }
67
+ end
68
+
69
+ # The engine's owned prefix and its deprecated predecessor.
70
+ PREFIX = "INSIKA_"
71
+ LEGACY_PREFIX = "HARNESS_"
72
+
73
+ # Prefixes the engine fully OWNS: an unknown key under one of these is a typo, not
74
+ # a foreign var. INSIKA_ (current) and HARNESS_ (legacy, still honored during the
75
+ # deprecation window). Deliberately NOT OPENCLAW_ (shared with the OpenClaw gateway
76
+ # product, which sets its own OPENCLAW_HOME/_STATE_DIR/… — the engine merely borrows
77
+ # 3 names for interop), nor LITESTREAM_ (the sidecar owns it), nor OTEL_ (the
78
+ # OpenTelemetry SDK owns its env).
79
+ OWNED_PREFIXES = [PREFIX, LEGACY_PREFIX].freeze
80
+
81
+ BOOLEANS = %w[1 0 true false yes no on off].freeze
82
+
83
+ def boolean?(value) = BOOLEANS.include?(value.to_s.strip.downcase)
84
+
85
+ # Truthy per the engine's convention (telemetry/turn_timing agree). The single
86
+ # home for "is this env flag on?".
87
+ def truthy?(value) = %w[1 true yes on].include?(value.to_s.strip.downcase)
88
+
89
+ def present?(value) = !value.nil? && !value.to_s.strip.empty?
90
+
91
+ def spec(name:, type: :string, secret: false, required: false, enum: nil, description: "")
92
+ Spec.new(name: name, type: type, secret: secret, required: required, enum: enum, description: description)
93
+ end
94
+
95
+ # The engine's own keys. Deployment/app keys (DEEPSEEK_*, CONSUMER_*, …) are NOT
96
+ # here — a root passes them as `extra:`.
97
+ DEFAULT = [
98
+ spec(name: "INSIKA_DB", type: :path, description: "SQLite path; durable config+state. Unset -> ephemeral memory."),
99
+ spec(name: "INSIKA_BIND", description: "Bind address for the transport server."),
100
+ spec(name: "INSIKA_PORT", type: :integer, description: "Port for the transport server."),
101
+ spec(name: "INSIKA_PUBLIC_URL", type: :url, description: "Public base URL (A2A agent card, links)."),
102
+ spec(name: "INSIKA_ENV", description: "Environment name shown in the Studio (falls back to RACK_ENV)."),
103
+ spec(name: "INSIKA_A2A_AGENT", description: "Agent id to expose over inbound A2A (opt-in)."),
104
+ spec(name: "INSIKA_A2A_REMOTES", type: :csv, description: "Comma-separated remote A2A endpoints."),
105
+ spec(name: "INSIKA_EGRESS_ALLOW_HTTP", type: :boolean, description: "Allow plain http egress from data-tools (default: https only)."),
106
+ spec(name: "INSIKA_EGRESS_ALLOW_PRIVATE", type: :boolean, description: "Allow egress to private/loopback ranges (SSRF guard off)."),
107
+ spec(name: "INSIKA_EGRESS_HOSTS", type: :csv, description: "Comma-separated host allowlist for data-tool egress."),
108
+ spec(name: "INSIKA_OTEL", type: :boolean, description: "Turn on OpenTelemetry export (opt-in)."),
109
+ spec(name: "INSIKA_MODEL_PRICING", description: "JSON rates table (USD per million tokens) for the estimated-cost attribute; unset -> no cost reported."),
110
+ spec(name: "INSIKA_TURN_TIMING", type: :boolean, description: "Emit per-turn TTFB breakdown in responses (opt-in)."),
111
+ spec(name: "INSIKA_SUBAGENT_DEPTH_CAP", type: :integer, description: "Max delegation depth in the subagent graph (default 5)."),
112
+ spec(name: "INSIKA_SUBAGENT_FANOUT_CAP", type: :integer, description: "Max parallel children in spawn_subagents (default 8)."),
113
+ spec(name: "INSIKA_CONFIG_STRICT", type: :boolean, description: "Refuse boot on any config finding instead of warning."),
114
+ spec(name: "INSIKA_BOOT_ID", description: "Boot generation id shared by all workers of one container start; the recovery task sweep runs once per id. Unset -> every boot sweeps."),
115
+ spec(name: "INSIKA_DRAIN_TIMEOUT", type: :integer, description: "Seconds a stopping worker waits for in-flight turns before abandoning them to the next boot's recovery (default 20)."),
116
+ spec(name: "INSIKA_TICK_INTERVAL", type: :integer, description: "Seconds between tick passes (outbox drain + stale recovery sweep). Default 60; 0 disables."),
117
+ spec(name: "INSIKA_TICK_STALE_AFTER", type: :integer, description: "Seconds a :queued/:running task must sit untouched before the tick sweeps it (default 900). Must exceed the largest turn_timeout of the deployment."),
118
+ spec(name: "INSIKA_TENANCY", enum: %w[single_tenant multi_tenant], description: "single_tenant (default: one operator credential) or multi_tenant (per-tenant + operator tokens resolved from the store)."),
119
+ spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in)."),
120
+ spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted."),
121
+ spec(name: "INSIKA_RELAY_DELIVER_URL", type: :url, description: "Consumer callback the relay POSTs each reply to."),
122
+ spec(name: "INSIKA_RELAY_DELIVER_TOKEN", secret: true, description: "Bearer the relay sends TO the consumer's callback (optional)."),
123
+ spec(name: "INSIKA_WIDGET_ORIGINS", type: :csv, description: "Exact-match origins allowed to embed the web widget. Unset -> the widget channel is not mounted."),
124
+ spec(name: "INSIKA_WIDGET_AGENTS", type: :csv, description: "Agent ids a widget visitor may address. Unset -> the widget channel is not mounted."),
125
+ spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
126
+ spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
127
+ spec(name: "OPENCLAW_PLUGIN_DIR", type: :path, description: "Directory of plugins to load."),
128
+ spec(name: "ADMIN_TOKEN", secret: true, description: "Studio login token; unset -> /studio fail-closed."),
129
+ spec(name: "OTEL_SERVICE_NAME", description: "Service name for OTEL spans (default: insika).")
130
+ ].freeze
131
+
132
+ # -- dual-read (rename compat) -------------------------------------
133
+
134
+ # HARNESS_X -> INSIKA_X for the deprecated alias of a canonical key; nil if `name`
135
+ # is not an INSIKA_ key.
136
+ def legacy_alias(name)
137
+ s = name.to_s
138
+ s.start_with?(PREFIX) ? LEGACY_PREFIX + s[PREFIX.length..] : nil
139
+ end
140
+
141
+ # The current (canonical) name for any owned key: HARNESS_X -> INSIKA_X; an INSIKA_
142
+ # key or any non-owned key is returned unchanged.
143
+ def canonical(name)
144
+ s = name.to_s
145
+ s.start_with?(LEGACY_PREFIX) ? PREFIX + s[LEGACY_PREFIX.length..] : s
146
+ end
147
+
148
+ def legacy?(name) = name.to_s.start_with?(LEGACY_PREFIX)
149
+ def owned?(name) = OWNED_PREFIXES.any? { |p| name.to_s.start_with?(p) }
150
+
151
+ # Reads a canonical INSIKA_* key, falling back to the deprecated HARNESS_* alias
152
+ # (new name wins). For the self-contained readers that get an env hash directly and
153
+ # so never see `reconcile_legacy!`'s process-wide backfill. -> value | nil.
154
+ def read(canonical_name, env = ENV)
155
+ value = env[canonical_name]
156
+ return value if present?(value)
157
+
158
+ (legacy = legacy_alias(canonical_name)) ? env[legacy] : value
159
+ end
160
+
161
+ # BOOT backfill: copies every still-set HARNESS_* var to its INSIKA_* name (new name
162
+ # wins if both are set) so the process ENV speaks the new names before any read.
163
+ # Generic (not schema-bound) so plugin/deployment HARNESS_* keys migrate too. WARNS
164
+ # once with the migrated list. -> [migrated legacy names]. Idempotent.
165
+ def reconcile_legacy!(env = ENV, warn: method(:default_warn))
166
+ migrated = env.keys.map(&:to_s).select { |k| k.start_with?(LEGACY_PREFIX) }.sort.each_with_object([]) do |legacy, acc|
167
+ canonical = PREFIX + legacy[LEGACY_PREFIX.length..]
168
+ next if present?(env[canonical]) # the new name already wins
169
+ next if env[legacy].nil?
170
+
171
+ env[canonical] = env[legacy]
172
+ acc << legacy
173
+ end
174
+ unless migrated.empty?
175
+ warn.call("deprecation: #{migrated.join(', ')} — HARNESS_* env vars are renamed to INSIKA_*; " \
176
+ "honored via the new names this run, please update your environment (legacy names removed in a future release)")
177
+ end
178
+ migrated
179
+ end
180
+
181
+ # -- validation ----------------------------------------------------
182
+
183
+ # -> [Finding]. `extra` = deployment/app specs to fold into the known set (and to
184
+ # widen unknown-key detection over their names). Never raises.
185
+ def validate(env = ENV, extra: [])
186
+ specs = index(DEFAULT + Array(extra))
187
+ findings = []
188
+
189
+ # VALUE + required checks over the KNOWN specs (owned or not — DEEPSEEK_API_KEY
190
+ # is not prefixed). Dual-read: the canonical name wins, the legacy alias is honored.
191
+ specs.each_value do |s|
192
+ legacy = legacy_alias(s.name)
193
+ set_name = if env.key?(s.name) then s.name
194
+ elsif legacy && env.key?(legacy) then legacy
195
+ end
196
+
197
+ if set_name.nil?
198
+ findings << Finding.new(key: s.name, kind: :missing_required, severity: :error,
199
+ message: "#{s.name} is required (unset)") if s.required?
200
+ next
201
+ end
202
+
203
+ msg = s.error_for(env[set_name])
204
+ findings << Finding.new(key: set_name, kind: :invalid, severity: :error, message: "#{set_name} #{msg}") if msg
205
+ end
206
+
207
+ # OWNED-PREFIX scan: a legacy alias of a known key is DEPRECATED (warn); any other
208
+ # owned key with no matching spec is UNKNOWN (a typo the runtime would ignore).
209
+ env.each_key do |raw|
210
+ key = raw.to_s
211
+ next unless owned?(key)
212
+
213
+ if legacy?(key) && specs.key?(canonical(key))
214
+ findings << Finding.new(key: key, kind: :deprecated, severity: :warn,
215
+ message: "#{key} is deprecated — rename to #{canonical(key)}")
216
+ elsif !specs.key?(canonical(key)) && !specs.key?(key)
217
+ findings << Finding.new(key: key, kind: :unknown, severity: :error,
218
+ message: "#{key} is not a known config key (typo? unknown key in an owned namespace)")
219
+ end
220
+ end
221
+
222
+ findings
223
+ end
224
+
225
+ # -> the specs the engine + a root know about (DEFAULT + extra), for `insika env`.
226
+ def known_specs(extra: []) = (DEFAULT + Array(extra)).sort_by(&:name)
227
+
228
+ # BOOT GATE. Validates the environment, WARNS every finding via `warn` (a callable
229
+ # taking a String), and RAISES ConfigError only when strict. `strict` defaults to
230
+ # the INSIKA_CONFIG_STRICT flag (HARNESS_CONFIG_STRICT still honored). -> [Finding]
231
+ # (also on the happy path). The root keeps booting on warnings (last-known-good).
232
+ def enforce!(env = ENV, extra: [], strict: nil, warn: method(:default_warn))
233
+ strict = truthy?(read("INSIKA_CONFIG_STRICT", env)) if strict.nil?
234
+ findings = validate(env, extra: extra)
235
+ findings.each { |f| warn.call("config: #{f.message}") }
236
+
237
+ errors = findings.select { |f| f.severity == :error }
238
+ raise Insika::ConfigError.new("strict config check refused boot", findings: errors) if strict && errors.any?
239
+
240
+ findings
241
+ end
242
+
243
+ def default_warn(message) = Kernel.warn("[config] #{message}")
244
+
245
+ # -- internal ------------------------------------------------------
246
+
247
+ def index(specs) = specs.each_with_object({}) { |s, acc| acc[s.name] = s }
248
+ end
249
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Single error taxonomy.
5
+ # General rule: an error becomes an event, a task has an explicit terminal
6
+ # state, a checkpoint is never corrupted.
7
+ class Error < StandardError; end
8
+
9
+ class ValidationError < Error; end # Malformed Command -> HTTP 422, no Task created
10
+ class NotFoundError < Error; end # nonexistent session/task/agent -> HTTP 404
11
+
12
+ # Policy Engine denied -> :policy_denied event, task :failed
13
+ class PolicyDenied < Error
14
+ attr_reader :policy, :reason
15
+
16
+ def initialize(message = nil, policy: nil, reason: nil)
17
+ @policy = policy
18
+ @reason = reason
19
+ super(message || "policy #{policy} denied: #{reason}")
20
+ end
21
+ end
22
+
23
+ # A required provider failed -> task :failed
24
+ class ContextError < Error
25
+ attr_reader :provider
26
+
27
+ def initialize(message = nil, provider: nil)
28
+ @provider = provider
29
+ super(message || "provider #{provider} failed")
30
+ end
31
+ end
32
+
33
+ # A provider/transport failure, wrapped by ProviderErrorClassifier with an
34
+ # ACTION classification (B9). The fields ride in the task's error record and
35
+ # the :task_failed event so the client envelope can tell fatal from
36
+ # retryable and quote the provider's own retry_after (A8). A bare
37
+ # `ProviderError.new("boom")` has an empty classification and adds nothing
38
+ # to the contract.
39
+ class ProviderError < Error
40
+ attr_reader :kind, :retryable, :retry_after
41
+
42
+ def initialize(message = nil, kind: nil, retryable: nil, retry_after: nil)
43
+ @kind = kind
44
+ @retryable = retryable
45
+ @retry_after = retry_after
46
+ super(message)
47
+ end
48
+
49
+ # the additive envelope fields, compacted — nil retry_after stays absent.
50
+ def classification
51
+ { kind: kind, retryable: retryable, retry_after: retry_after }.compact
52
+ end
53
+ end
54
+
55
+ # The hard budget refused the turn (WS2): the (tenant, agent) spend in the
56
+ # window already met its cap BEFORE the turn ran. A typed, retryable failure —
57
+ # the envelope reads `budget_exceeded` + `retry_after` (seconds until the
58
+ # window rolls) — never a silent drop. `window` is :daily | :monthly.
59
+ class BudgetExceeded < Error
60
+ attr_reader :window, :retry_after
61
+
62
+ def initialize(message = nil, window: nil, retry_after: nil)
63
+ @window = window
64
+ @retry_after = retry_after
65
+ super(message || "budget exceeded (#{window})")
66
+ end
67
+
68
+ def classification
69
+ { kind: :budget_exceeded, retryable: true, retry_after: retry_after }.compact
70
+ end
71
+ end
72
+
73
+ # The circuit breaker refused the turn WITHOUT touching the provider (WS3):
74
+ # the (tenant, provider/model) saw `after` failures within `within` seconds.
75
+ # Typed + retryable — the envelope reads `circuit_open` + `retry_after`
76
+ # (seconds until the cooldown lets a half-open trial through).
77
+ class CircuitOpenError < Error
78
+ attr_reader :ref, :retry_after
79
+
80
+ def initialize(message = nil, ref: nil, retry_after: nil)
81
+ @ref = ref
82
+ @retry_after = retry_after
83
+ super(message || "circuit open for #{ref}")
84
+ end
85
+
86
+ def classification
87
+ { kind: :circuit_open, retryable: true, retry_after: retry_after }.compact
88
+ end
89
+ end
90
+ class StoreError < Error; end # persistence backend failed -> task :failed
91
+ class CancelledError < Error; end # cooperative cancellation -> task :cancelled
92
+
93
+ # Stage timeout overflow. Inside the Insika namespace this constant
94
+ # shadows the stdlib ::Timeout::Error — reference it without :: in here
95
+ # (the contract forbids stdlib Timeout.timeout anyway).
96
+ class TimeoutError < Error
97
+ attr_reader :stage
98
+
99
+ def initialize(message = nil, stage: nil)
100
+ @stage = stage
101
+ super(message || "timeout at stage #{stage}")
102
+ end
103
+ end
104
+
105
+ # Capability resolution failed -> task :failed at the
106
+ # :capability stage. Root of the subtree; it does NOT get its own event —
107
+ # it propagates through the existing :error/:task_failed events, same
108
+ # discipline as the taxonomy. Never raised directly (only its subclasses).
109
+ class CapabilityError < Error; end
110
+
111
+ # 0 candidates left after availability + deny.
112
+ class CapabilityUnavailable < CapabilityError
113
+ attr_reader :capability
114
+
115
+ def initialize(message = nil, capability: nil)
116
+ @capability = capability
117
+ super(message || "capability #{capability} has no available provider")
118
+ end
119
+ end
120
+
121
+ # >=2 candidates tied at the top (same priority AND same plugin) -> a
122
+ # configuration error, NEVER a silent choice. `candidates` carries enough
123
+ # for the operator to break the tie in the manifest.
124
+ class CapabilityAmbiguous < CapabilityError
125
+ attr_reader :capability, :candidates
126
+
127
+ def initialize(message = nil, capability: nil, candidates: [])
128
+ @capability = capability
129
+ @candidates = candidates
130
+ super(message || "capability #{capability} ambiguous between #{candidates.inspect}")
131
+ end
132
+ end
133
+
134
+ # Subagent graph integrity. Raised at DEFINITION-time
135
+ # (CreateAgent/UpdateAgent/boot) by SubagentGraph.validate! — a subagents
136
+ # allowlist that forms a cycle or exceeds the depth cap is a configuration
137
+ # error, never a runtime surprise. A ValidationError so the authoring Command
138
+ # fails cleanly (HTTP 422, no profile persisted).
139
+ class SubagentError < ValidationError; end
140
+
141
+ class SubagentCycleError < SubagentError
142
+ attr_reader :cycle
143
+
144
+ def initialize(message = nil, cycle: [])
145
+ @cycle = cycle
146
+ super(message || "subagent cycle detected: #{cycle.join(' -> ')}")
147
+ end
148
+ end
149
+
150
+ class SubagentDepthExceeded < SubagentError
151
+ attr_reader :depth, :cap
152
+
153
+ def initialize(message = nil, depth: nil, cap: nil)
154
+ @depth = depth
155
+ @cap = cap
156
+ super(message || "subagent depth #{depth} exceeds cap #{cap}")
157
+ end
158
+ end
159
+
160
+ # Workflow I/O contract violation. A workflow may declare an
161
+ # `input_schema` / `output_schema`; a value that does not conform is rejected.
162
+ # INPUT is validated SYNCHRONOUSLY (TriggerWorkflow) so it is a ValidationError
163
+ # -> HTTP 422, no run created. OUTPUT is validated inside the fiber after the
164
+ # workflow returns -> task :failed at the :workflow_schema stage. `errors` is the
165
+ # per-field detail (dry-schema-compatible `#errors.to_h`); `phase` is :input|:output.
166
+ class WorkflowSchemaError < ValidationError
167
+ attr_reader :phase, :errors
168
+
169
+ def initialize(message = nil, phase: nil, errors: {})
170
+ @phase = phase
171
+ @errors = errors || {}
172
+ detail = @errors.map { |field, msgs| "#{field}: #{Array(msgs).join(', ')}" }.join("; ")
173
+ base = message || "workflow #{phase} failed schema validation"
174
+ super(detail.empty? ? base : "#{base} (#{detail})")
175
+ end
176
+ end
177
+
178
+ # A channel could not hand a reply to its recipient. NOT a turn
179
+ # failure: the turn already completed and its answer is durable in the session —
180
+ # what failed is the delivery, which lives in the OutboxStore with its own status
181
+ # and its own bounded retry. Raised by a channel's `deliver` so the dispatcher can
182
+ # tell "the recipient refused" from "the engine has a bug".
183
+ class DeliveryError < Error; end
184
+
185
+ # Strict configuration violation (— OpenClaw's config discipline:
186
+ # "recusa boot com chave desconhecida, no silent config compat"). Raised by
187
+ # EnvSchema.enforce! at boot ONLY when strictness is on (INSIKA_CONFIG_STRICT) —
188
+ # by default a bad key WARNS and the engine still boots (last-known-good: a rotated
189
+ # env or a typo never takes the whole service down). `findings` carries the
190
+ # per-key detail (EnvSchema::Finding) so the operator can fix the config.
191
+ class ConfigError < Error
192
+ attr_reader :findings
193
+
194
+ def initialize(message = nil, findings: [])
195
+ @findings = findings || []
196
+ detail = @findings.map { |f| f.respond_to?(:message) ? f.message : f.to_s }.join("; ")
197
+ base = message || "strict config check failed"
198
+ super(detail.empty? ? base : "#{base}: #{detail}")
199
+ end
200
+ end
201
+ end