insika 0.0.1 → 0.1.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 (260) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +351 -0
  6. data/docs/AGENTS.md +494 -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 +100 -0
  11. data/docs/DEPLOY.md +334 -0
  12. data/docs/EMBEDDING.md +194 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +231 -0
  15. data/docs/OBSERVABILITY.md +365 -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 +362 -0
  22. data/docs/SKILLS.md +98 -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 +188 -0
  34. data/lib/insika/allowlist.rb +28 -0
  35. data/lib/insika/baseline_store.rb +74 -0
  36. data/lib/insika/capability/resolved_tool.rb +34 -0
  37. data/lib/insika/capability_registry.rb +112 -0
  38. data/lib/insika/channel_delivery.rb +150 -0
  39. data/lib/insika/channel_registry.rb +30 -0
  40. data/lib/insika/channels/relay.rb +178 -0
  41. data/lib/insika/channels/web/widget.js +283 -0
  42. data/lib/insika/channels/web.rb +211 -0
  43. data/lib/insika/chat_builder.rb +254 -0
  44. data/lib/insika/checkpoint.rb +13 -0
  45. data/lib/insika/checkpoint_store.rb +153 -0
  46. data/lib/insika/coercion.rb +50 -0
  47. data/lib/insika/command.rb +32 -0
  48. data/lib/insika/command_bus.rb +39 -0
  49. data/lib/insika/commands/agent_payload.rb +41 -0
  50. data/lib/insika/commands/approve_action.rb +46 -0
  51. data/lib/insika/commands/cancel_task.rb +33 -0
  52. data/lib/insika/commands/create_agent.rb +54 -0
  53. data/lib/insika/commands/create_session.rb +67 -0
  54. data/lib/insika/commands/delete_agent.rb +33 -0
  55. data/lib/insika/commands/delete_agent_file.rb +50 -0
  56. data/lib/insika/commands/delete_data_tool.rb +33 -0
  57. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  58. data/lib/insika/commands/delete_mcp.rb +30 -0
  59. data/lib/insika/commands/delete_system_file.rb +29 -0
  60. data/lib/insika/commands/gate_refinement.rb +245 -0
  61. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  62. data/lib/insika/commands/import_tools.rb +81 -0
  63. data/lib/insika/commands/memory_add_note.rb +32 -0
  64. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  65. data/lib/insika/commands/memory_put_fact.rb +35 -0
  66. data/lib/insika/commands/pause_task.rb +29 -0
  67. data/lib/insika/commands/resolve_refinement.rb +126 -0
  68. data/lib/insika/commands/restore_agent_file.rb +36 -0
  69. data/lib/insika/commands/restore_data_tool.rb +34 -0
  70. data/lib/insika/commands/restore_system_file.rb +31 -0
  71. data/lib/insika/commands/resume_task.rb +85 -0
  72. data/lib/insika/commands/run_refinement.rb +133 -0
  73. data/lib/insika/commands/send_message.rb +150 -0
  74. data/lib/insika/commands/set_agent_tools.rb +39 -0
  75. data/lib/insika/commands/set_skill_agents.rb +71 -0
  76. data/lib/insika/commands/trigger_workflow.rb +80 -0
  77. data/lib/insika/commands/update_agent.rb +49 -0
  78. data/lib/insika/commands/update_settings.rb +33 -0
  79. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  80. data/lib/insika/commands/upsert_mcp.rb +32 -0
  81. data/lib/insika/commands/write_agent_file.rb +57 -0
  82. data/lib/insika/commands/write_data_tool.rb +43 -0
  83. data/lib/insika/commands/write_golden.rb +58 -0
  84. data/lib/insika/commands/write_skill.rb +50 -0
  85. data/lib/insika/commands/write_system_file.rb +31 -0
  86. data/lib/insika/config_store.rb +85 -0
  87. data/lib/insika/context/builder.rb +166 -0
  88. data/lib/insika/context/catalog_provider.rb +23 -0
  89. data/lib/insika/context/fragment.rb +19 -0
  90. data/lib/insika/context/priority.rb +29 -0
  91. data/lib/insika/context/provider.rb +19 -0
  92. data/lib/insika/context/providers/memory.rb +60 -0
  93. data/lib/insika/context/providers/prompt.rb +105 -0
  94. data/lib/insika/context/providers/request.rb +32 -0
  95. data/lib/insika/context/providers/session.rb +108 -0
  96. data/lib/insika/context/providers/skill.rb +20 -0
  97. data/lib/insika/context/providers/tool_search.rb +20 -0
  98. data/lib/insika/delegation_store.rb +153 -0
  99. data/lib/insika/doctor.rb +294 -0
  100. data/lib/insika/dsl/definition.rb +55 -0
  101. data/lib/insika/dsl/runtime.rb +379 -0
  102. data/lib/insika/dsl/server_boot.rb +97 -0
  103. data/lib/insika/dsl/system.rb +93 -0
  104. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  105. data/lib/insika/dsl.rb +307 -0
  106. data/lib/insika/edge_limiter.rb +130 -0
  107. data/lib/insika/egress_guard.rb +75 -0
  108. data/lib/insika/env_schema.rb +246 -0
  109. data/lib/insika/errors.rb +145 -0
  110. data/lib/insika/evals/assertions.rb +247 -0
  111. data/lib/insika/evals/baseline.rb +69 -0
  112. data/lib/insika/evals/golden.rb +172 -0
  113. data/lib/insika/evals/judge.rb +225 -0
  114. data/lib/insika/evals/pairwise.rb +178 -0
  115. data/lib/insika/evals/report.rb +115 -0
  116. data/lib/insika/evals/runner.rb +141 -0
  117. data/lib/insika/evals/transport.rb +178 -0
  118. data/lib/insika/event.rb +18 -0
  119. data/lib/insika/event_stream.rb +114 -0
  120. data/lib/insika/executor.rb +1680 -0
  121. data/lib/insika/frontmatter.rb +42 -0
  122. data/lib/insika/golden_store.rb +145 -0
  123. data/lib/insika/hooks.rb +48 -0
  124. data/lib/insika/http_client.rb +63 -0
  125. data/lib/insika/inbound_log.rb +84 -0
  126. data/lib/insika/llm_configurator.rb +99 -0
  127. data/lib/insika/llm_provider_store.rb +83 -0
  128. data/lib/insika/mcp_http_client.rb +67 -0
  129. data/lib/insika/mcp_store.rb +115 -0
  130. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  131. data/lib/insika/memory_store.rb +93 -0
  132. data/lib/insika/message_origin.rb +76 -0
  133. data/lib/insika/middleware.rb +36 -0
  134. data/lib/insika/model_policy.rb +52 -0
  135. data/lib/insika/model_resolver.rb +176 -0
  136. data/lib/insika/model_selection.rb +114 -0
  137. data/lib/insika/onboarding.rb +208 -0
  138. data/lib/insika/outbox_store.rb +166 -0
  139. data/lib/insika/overlay_tool_registry.rb +103 -0
  140. data/lib/insika/pack.rb +102 -0
  141. data/lib/insika/pack_importer.rb +121 -0
  142. data/lib/insika/pending_action_store.rb +120 -0
  143. data/lib/insika/plugin/loader.rb +356 -0
  144. data/lib/insika/plugin.rb +35 -0
  145. data/lib/insika/policy/engine.rb +83 -0
  146. data/lib/insika/policy/policy.rb +120 -0
  147. data/lib/insika/policy_registry.rb +23 -0
  148. data/lib/insika/profile_source.rb +137 -0
  149. data/lib/insika/prompt_catalog.rb +61 -0
  150. data/lib/insika/queue_policy.rb +167 -0
  151. data/lib/insika/recovery.rb +127 -0
  152. data/lib/insika/refinement/candidate.rb +159 -0
  153. data/lib/insika/refinement/evidence_collector.rb +371 -0
  154. data/lib/insika/refinement/gate.rb +234 -0
  155. data/lib/insika/refinement/panel.rb +222 -0
  156. data/lib/insika/refinement/proposer.rb +262 -0
  157. data/lib/insika/refinement_store.rb +295 -0
  158. data/lib/insika/registry.rb +59 -0
  159. data/lib/insika/safety/config.rb +109 -0
  160. data/lib/insika/safety/detectors.rb +176 -0
  161. data/lib/insika/safety/factory.rb +102 -0
  162. data/lib/insika/safety/input_guardrail.rb +87 -0
  163. data/lib/insika/safety/moderator.rb +86 -0
  164. data/lib/insika/safety/output_filter.rb +79 -0
  165. data/lib/insika/safety/output_validator.rb +101 -0
  166. data/lib/insika/safety/safe_responses.rb +47 -0
  167. data/lib/insika/sandbox/boundary.rb +93 -0
  168. data/lib/insika/sandbox/docker.rb +74 -0
  169. data/lib/insika/sandbox/local.rb +33 -0
  170. data/lib/insika/sandbox/runner.rb +80 -0
  171. data/lib/insika/sandbox.rb +85 -0
  172. data/lib/insika/schema_guard.rb +147 -0
  173. data/lib/insika/secret_masking.rb +34 -0
  174. data/lib/insika/server/a2a/agent_card.rb +27 -0
  175. data/lib/insika/server/a2a/app.rb +112 -0
  176. data/lib/insika/server/a2a/client.rb +101 -0
  177. data/lib/insika/server/a2a/errors.rb +32 -0
  178. data/lib/insika/server/a2a/http.rb +42 -0
  179. data/lib/insika/server/a2a/message.rb +27 -0
  180. data/lib/insika/server/a2a/protocol.rb +45 -0
  181. data/lib/insika/server/a2a/remotes.rb +25 -0
  182. data/lib/insika/server/a2a/task_projection.rb +40 -0
  183. data/lib/insika/server/admin_auth.rb +29 -0
  184. data/lib/insika/server/app.rb +850 -0
  185. data/lib/insika/server/boot.rb +119 -0
  186. data/lib/insika/server/rack_app.rb +110 -0
  187. data/lib/insika/server/responses.rb +155 -0
  188. data/lib/insika/server/sse_body.rb +96 -0
  189. data/lib/insika/session_actor.rb +162 -0
  190. data/lib/insika/session_store.rb +143 -0
  191. data/lib/insika/settings_store.rb +154 -0
  192. data/lib/insika/shutdown.rb +125 -0
  193. data/lib/insika/skill_catalog.rb +113 -0
  194. data/lib/insika/skill_store.rb +79 -0
  195. data/lib/insika/steer_injector.rb +110 -0
  196. data/lib/insika/store.rb +52 -0
  197. data/lib/insika/stores/memory.rb +123 -0
  198. data/lib/insika/stores/sqlite.rb +183 -0
  199. data/lib/insika/studio/app.rb +1571 -0
  200. data/lib/insika/studio/assets/dist/application.css +1 -0
  201. data/lib/insika/studio/assets/dist/application.js +69 -0
  202. data/lib/insika/studio/forms.rb +340 -0
  203. data/lib/insika/studio/nav_icons.rb +31 -0
  204. data/lib/insika/studio/views/_message.erb +44 -0
  205. data/lib/insika/studio/views/agent_detail.erb +285 -0
  206. data/lib/insika/studio/views/agents.erb +63 -0
  207. data/lib/insika/studio/views/approvals.erb +41 -0
  208. data/lib/insika/studio/views/chats.erb +34 -0
  209. data/lib/insika/studio/views/evals.erb +83 -0
  210. data/lib/insika/studio/views/home.erb +72 -0
  211. data/lib/insika/studio/views/layout.erb +94 -0
  212. data/lib/insika/studio/views/login.erb +17 -0
  213. data/lib/insika/studio/views/mcp.erb +91 -0
  214. data/lib/insika/studio/views/not_found.erb +5 -0
  215. data/lib/insika/studio/views/playground.erb +47 -0
  216. data/lib/insika/studio/views/refinement.erb +234 -0
  217. data/lib/insika/studio/views/session.erb +62 -0
  218. data/lib/insika/studio/views/settings.erb +173 -0
  219. data/lib/insika/studio/views/skills.erb +86 -0
  220. data/lib/insika/studio/views/system_files.erb +65 -0
  221. data/lib/insika/studio/views/task.erb +105 -0
  222. data/lib/insika/studio/views/tasks.erb +33 -0
  223. data/lib/insika/studio/views/tool_edit.erb +107 -0
  224. data/lib/insika/studio/views/tools.erb +89 -0
  225. data/lib/insika/subagent_graph.rb +96 -0
  226. data/lib/insika/system_file_store.rb +96 -0
  227. data/lib/insika/task_actor.rb +128 -0
  228. data/lib/insika/task_store.rb +250 -0
  229. data/lib/insika/telemetry/pricing.rb +104 -0
  230. data/lib/insika/telemetry/recorder.rb +228 -0
  231. data/lib/insika/telemetry.rb +127 -0
  232. data/lib/insika/testing/store_contract.rb +270 -0
  233. data/lib/insika/token_estimator.rb +16 -0
  234. data/lib/insika/tool_assembly.rb +140 -0
  235. data/lib/insika/tool_catalog.rb +89 -0
  236. data/lib/insika/tool_definition.rb +518 -0
  237. data/lib/insika/tool_envelope.rb +140 -0
  238. data/lib/insika/tool_manifest.rb +218 -0
  239. data/lib/insika/tool_registry.rb +21 -0
  240. data/lib/insika/tool_store.rb +135 -0
  241. data/lib/insika/tool_trace_store.rb +92 -0
  242. data/lib/insika/tools/a2a_remote.rb +48 -0
  243. data/lib/insika/tools/agent_enum.rb +68 -0
  244. data/lib/insika/tools/concurrency.rb +54 -0
  245. data/lib/insika/tools/data_defined_tool.rb +220 -0
  246. data/lib/insika/tools/load_skill.rb +41 -0
  247. data/lib/insika/tools/remember.rb +53 -0
  248. data/lib/insika/tools/subagent.rb +75 -0
  249. data/lib/insika/tools/subagents.rb +77 -0
  250. data/lib/insika/tools/tool_search.rb +94 -0
  251. data/lib/insika/turn_output.rb +139 -0
  252. data/lib/insika/turn_state.rb +158 -0
  253. data/lib/insika/turn_timing.rb +56 -0
  254. data/lib/insika/usage_ledger.rb +47 -0
  255. data/lib/insika/version.rb +3 -1
  256. data/lib/insika/wiring/graph.rb +198 -0
  257. data/lib/insika/workflow.rb +185 -0
  258. data/lib/insika/workflow_registry.rb +33 -0
  259. data/lib/insika.rb +203 -4
  260. metadata +395 -8
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # STRICT config, environment layer (item 23 / §8.1). 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_*/ACHEI_*) 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_*, ACHEI_*, …) 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, item 34)."),
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 (item 23)."),
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 (RFC-0016). 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, RFC-0016 A3)."),
116
+ spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in, item 20)."),
117
+ spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted (RFC-0011 §6)."),
118
+ spec(name: "INSIKA_RELAY_DELIVER_URL", type: :url, description: "Consumer callback the relay POSTs each reply to."),
119
+ spec(name: "INSIKA_RELAY_DELIVER_TOKEN", secret: true, description: "Bearer the relay sends TO the consumer's callback (optional)."),
120
+ spec(name: "INSIKA_WIDGET_ORIGINS", type: :csv, description: "Exact-match origins allowed to embed the web widget. Unset -> the widget channel is not mounted (RFC-0011 §5)."),
121
+ spec(name: "INSIKA_WIDGET_AGENTS", type: :csv, description: "Agent ids a widget visitor may address. Unset -> the widget channel is not mounted."),
122
+ spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
123
+ spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
124
+ spec(name: "OPENCLAW_PLUGIN_DIR", type: :path, description: "Directory of plugins to load."),
125
+ spec(name: "ADMIN_TOKEN", secret: true, description: "Studio login token; unset -> /studio fail-closed."),
126
+ spec(name: "OTEL_SERVICE_NAME", description: "Service name for OTEL spans (default: insika).")
127
+ ].freeze
128
+
129
+ # -- dual-read (rename compat) -------------------------------------
130
+
131
+ # HARNESS_X -> INSIKA_X for the deprecated alias of a canonical key; nil if `name`
132
+ # is not an INSIKA_ key.
133
+ def legacy_alias(name)
134
+ s = name.to_s
135
+ s.start_with?(PREFIX) ? LEGACY_PREFIX + s[PREFIX.length..] : nil
136
+ end
137
+
138
+ # The current (canonical) name for any owned key: HARNESS_X -> INSIKA_X; an INSIKA_
139
+ # key or any non-owned key is returned unchanged.
140
+ def canonical(name)
141
+ s = name.to_s
142
+ s.start_with?(LEGACY_PREFIX) ? PREFIX + s[LEGACY_PREFIX.length..] : s
143
+ end
144
+
145
+ def legacy?(name) = name.to_s.start_with?(LEGACY_PREFIX)
146
+ def owned?(name) = OWNED_PREFIXES.any? { |p| name.to_s.start_with?(p) }
147
+
148
+ # Reads a canonical INSIKA_* key, falling back to the deprecated HARNESS_* alias
149
+ # (new name wins). For the self-contained readers that get an env hash directly and
150
+ # so never see `reconcile_legacy!`'s process-wide backfill. -> value | nil.
151
+ def read(canonical_name, env = ENV)
152
+ value = env[canonical_name]
153
+ return value if present?(value)
154
+
155
+ (legacy = legacy_alias(canonical_name)) ? env[legacy] : value
156
+ end
157
+
158
+ # BOOT backfill: copies every still-set HARNESS_* var to its INSIKA_* name (new name
159
+ # wins if both are set) so the process ENV speaks the new names before any read.
160
+ # Generic (not schema-bound) so plugin/deployment HARNESS_* keys migrate too. WARNS
161
+ # once with the migrated list. -> [migrated legacy names]. Idempotent.
162
+ def reconcile_legacy!(env = ENV, warn: method(:default_warn))
163
+ migrated = env.keys.map(&:to_s).select { |k| k.start_with?(LEGACY_PREFIX) }.sort.each_with_object([]) do |legacy, acc|
164
+ canonical = PREFIX + legacy[LEGACY_PREFIX.length..]
165
+ next if present?(env[canonical]) # the new name already wins
166
+ next if env[legacy].nil?
167
+
168
+ env[canonical] = env[legacy]
169
+ acc << legacy
170
+ end
171
+ unless migrated.empty?
172
+ warn.call("deprecation: #{migrated.join(', ')} — HARNESS_* env vars are renamed to INSIKA_*; " \
173
+ "honored via the new names this run, please update your environment (legacy names removed in a future release)")
174
+ end
175
+ migrated
176
+ end
177
+
178
+ # -- validation ----------------------------------------------------
179
+
180
+ # -> [Finding]. `extra` = deployment/app specs to fold into the known set (and to
181
+ # widen unknown-key detection over their names). Never raises.
182
+ def validate(env = ENV, extra: [])
183
+ specs = index(DEFAULT + Array(extra))
184
+ findings = []
185
+
186
+ # VALUE + required checks over the KNOWN specs (owned or not — DEEPSEEK_API_KEY
187
+ # is not prefixed). Dual-read: the canonical name wins, the legacy alias is honored.
188
+ specs.each_value do |s|
189
+ legacy = legacy_alias(s.name)
190
+ set_name = if env.key?(s.name) then s.name
191
+ elsif legacy && env.key?(legacy) then legacy
192
+ end
193
+
194
+ if set_name.nil?
195
+ findings << Finding.new(key: s.name, kind: :missing_required, severity: :error,
196
+ message: "#{s.name} is required (unset)") if s.required?
197
+ next
198
+ end
199
+
200
+ msg = s.error_for(env[set_name])
201
+ findings << Finding.new(key: set_name, kind: :invalid, severity: :error, message: "#{set_name} #{msg}") if msg
202
+ end
203
+
204
+ # OWNED-PREFIX scan: a legacy alias of a known key is DEPRECATED (warn); any other
205
+ # owned key with no matching spec is UNKNOWN (a typo the runtime would ignore).
206
+ env.each_key do |raw|
207
+ key = raw.to_s
208
+ next unless owned?(key)
209
+
210
+ if legacy?(key) && specs.key?(canonical(key))
211
+ findings << Finding.new(key: key, kind: :deprecated, severity: :warn,
212
+ message: "#{key} is deprecated — rename to #{canonical(key)}")
213
+ elsif !specs.key?(canonical(key)) && !specs.key?(key)
214
+ findings << Finding.new(key: key, kind: :unknown, severity: :error,
215
+ message: "#{key} is not a known config key (typo? unknown key in an owned namespace)")
216
+ end
217
+ end
218
+
219
+ findings
220
+ end
221
+
222
+ # -> the specs the engine + a root know about (DEFAULT + extra), for `insika env`.
223
+ def known_specs(extra: []) = (DEFAULT + Array(extra)).sort_by(&:name)
224
+
225
+ # BOOT GATE. Validates the environment, WARNS every finding via `warn` (a callable
226
+ # taking a String), and RAISES ConfigError only when strict. `strict` defaults to
227
+ # the INSIKA_CONFIG_STRICT flag (HARNESS_CONFIG_STRICT still honored). -> [Finding]
228
+ # (also on the happy path). The root keeps booting on warnings (last-known-good).
229
+ def enforce!(env = ENV, extra: [], strict: nil, warn: method(:default_warn))
230
+ strict = truthy?(read("INSIKA_CONFIG_STRICT", env)) if strict.nil?
231
+ findings = validate(env, extra: extra)
232
+ findings.each { |f| warn.call("config: #{f.message}") }
233
+
234
+ errors = findings.select { |f| f.severity == :error }
235
+ raise Insika::ConfigError.new("strict config check refused boot", findings: errors) if strict && errors.any?
236
+
237
+ findings
238
+ end
239
+
240
+ def default_warn(message) = Kernel.warn("[config] #{message}")
241
+
242
+ # -- internal ------------------------------------------------------
243
+
244
+ def index(specs) = specs.each_with_object({}) { |s, acc| acc[s.name] = s }
245
+ end
246
+ end
@@ -0,0 +1,145 @@
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
+ class ProviderError < Error; end # RubyLLM exhausted retries -> task :failed
34
+ class StoreError < Error; end # persistence backend failed -> task :failed
35
+ class CancelledError < Error; end # cooperative cancellation -> task :cancelled
36
+
37
+ # Stage timeout overflow. Inside the Insika namespace this constant
38
+ # shadows the stdlib ::Timeout::Error — reference it without :: in here
39
+ # (the contract forbids stdlib Timeout.timeout anyway).
40
+ class TimeoutError < Error
41
+ attr_reader :stage
42
+
43
+ def initialize(message = nil, stage: nil)
44
+ @stage = stage
45
+ super(message || "timeout at stage #{stage}")
46
+ end
47
+ end
48
+
49
+ # Capability resolution failed -> task :failed at the
50
+ # :capability stage. Root of the subtree; it does NOT get its own event —
51
+ # it propagates through the existing :error/:task_failed events, same
52
+ # discipline as the taxonomy. Never raised directly (only its subclasses).
53
+ class CapabilityError < Error; end
54
+
55
+ # 0 candidates left after availability + deny.
56
+ class CapabilityUnavailable < CapabilityError
57
+ attr_reader :capability
58
+
59
+ def initialize(message = nil, capability: nil)
60
+ @capability = capability
61
+ super(message || "capability #{capability} has no available provider")
62
+ end
63
+ end
64
+
65
+ # >=2 candidates tied at the top (same priority AND same plugin) -> a
66
+ # configuration error, NEVER a silent choice. `candidates` carries enough
67
+ # for the operator to break the tie in the manifest.
68
+ class CapabilityAmbiguous < CapabilityError
69
+ attr_reader :capability, :candidates
70
+
71
+ def initialize(message = nil, capability: nil, candidates: [])
72
+ @capability = capability
73
+ @candidates = candidates
74
+ super(message || "capability #{capability} ambiguous between #{candidates.inspect}")
75
+ end
76
+ end
77
+
78
+ # Subagent graph integrity (RFC-0010 §4.4). Raised at DEFINITION-time
79
+ # (CreateAgent/UpdateAgent/boot) by SubagentGraph.validate! — a subagents
80
+ # allowlist that forms a cycle or exceeds the depth cap is a configuration
81
+ # error, never a runtime surprise. A ValidationError so the authoring Command
82
+ # fails cleanly (HTTP 422, no profile persisted).
83
+ class SubagentError < ValidationError; end
84
+
85
+ class SubagentCycleError < SubagentError
86
+ attr_reader :cycle
87
+
88
+ def initialize(message = nil, cycle: [])
89
+ @cycle = cycle
90
+ super(message || "subagent cycle detected: #{cycle.join(' -> ')}")
91
+ end
92
+ end
93
+
94
+ class SubagentDepthExceeded < SubagentError
95
+ attr_reader :depth, :cap
96
+
97
+ def initialize(message = nil, depth: nil, cap: nil)
98
+ @depth = depth
99
+ @cap = cap
100
+ super(message || "subagent depth #{depth} exceeds cap #{cap}")
101
+ end
102
+ end
103
+
104
+ # Workflow I/O contract violation (item 22 / §4.4). A workflow may declare an
105
+ # `input_schema` / `output_schema`; a value that does not conform is rejected.
106
+ # INPUT is validated SYNCHRONOUSLY (TriggerWorkflow) so it is a ValidationError
107
+ # -> HTTP 422, no run created. OUTPUT is validated inside the fiber after the
108
+ # workflow returns -> task :failed at the :workflow_schema stage. `errors` is the
109
+ # per-field detail (dry-schema-compatible `#errors.to_h`); `phase` is :input|:output.
110
+ class WorkflowSchemaError < ValidationError
111
+ attr_reader :phase, :errors
112
+
113
+ def initialize(message = nil, phase: nil, errors: {})
114
+ @phase = phase
115
+ @errors = errors || {}
116
+ detail = @errors.map { |field, msgs| "#{field}: #{Array(msgs).join(', ')}" }.join("; ")
117
+ base = message || "workflow #{phase} failed schema validation"
118
+ super(detail.empty? ? base : "#{base} (#{detail})")
119
+ end
120
+ end
121
+
122
+ # A channel could not hand a reply to its recipient (RFC-0011 §6.5). NOT a turn
123
+ # failure: the turn already completed and its answer is durable in the session —
124
+ # what failed is the delivery, which lives in the OutboxStore with its own status
125
+ # and its own bounded retry. Raised by a channel's `deliver` so the dispatcher can
126
+ # tell "the recipient refused" from "the engine has a bug".
127
+ class DeliveryError < Error; end
128
+
129
+ # Strict configuration violation (item 23 / §8.1 — OpenClaw's config discipline:
130
+ # "recusa boot com chave desconhecida, no silent config compat"). Raised by
131
+ # EnvSchema.enforce! at boot ONLY when strictness is on (INSIKA_CONFIG_STRICT) —
132
+ # by default a bad key WARNS and the engine still boots (last-known-good: a rotated
133
+ # env or a typo never takes the whole service down). `findings` carries the
134
+ # per-key detail (EnvSchema::Finding) so the operator can fix the config.
135
+ class ConfigError < Error
136
+ attr_reader :findings
137
+
138
+ def initialize(message = nil, findings: [])
139
+ @findings = findings || []
140
+ detail = @findings.map { |f| f.respond_to?(:message) ? f.message : f.to_s }.join("; ")
141
+ base = message || "strict config check failed"
142
+ super(detail.empty? ? base : "#{base}: #{detail}")
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ # D4 (RFC-0009): the PII/secret patterns live in the RUNTIME (single source of
4
+ # truth) — the eval consumes them rather than keeping a divergent copy. Since the
5
+ # module moved under `lib/`, `Safety::Detectors` is loaded by `insika.rb` before this
6
+ # file; the explicit climb out of `evals/` that used to be here is gone.
7
+
8
+ module Insika
9
+ module Evals
10
+ # What the runner extracts from ONE replayed conversation, entirely from the
11
+ # public SSE stream of POST /v1/responses (no store reads — the eval stays a
12
+ # client). The assertion engine is pure over this value, so it's unit-testable
13
+ # offline without a server.
14
+ #
15
+ # output_text: the final assistant text (for content checks)
16
+ # tool_calls: [{ "name" =>, "status" => }] captured from the stream's tool events
17
+ # error: transport/turn error string, or nil on a clean turn
18
+ TurnResult = Struct.new(:output_text, :tool_calls, :error, keyword_init: true) do
19
+ def tool_names = Array(tool_calls).map { |t| (t["name"] || t[:name]).to_s }
20
+
21
+ # A tool call whose status is anything but a success ("ok"/2xx/"success").
22
+ def errored_tools
23
+ Array(tool_calls).reject { |t| Assertions.ok_status?(t["status"] || t[:status]) }
24
+ end
25
+ end
26
+
27
+ # A single check within a case (e.g. "tool:shipping_quote", "must_not:pii_leak").
28
+ Check = Struct.new(:name, :pass, :detail, keyword_init: true)
29
+
30
+ # The verdict for one golden case. `judge` (a Judge::Verdict) is attached AFTER
31
+ # the deterministic pass when the case has a rubric and a judge is configured;
32
+ # until then a rubric'd case is `judge_pending?` — it reads as "not fully
33
+ # evaluated", never a silent pass. A case passes only if the deterministic checks
34
+ # pass AND (there's no judge verdict OR it passed).
35
+ #
36
+ # `skipped` (a reason, nil = it ran) is the THIRD outcome (RFC-0014 §3.2): the
37
+ # deployment lacks something the case declared it needs, so there was nothing to
38
+ # assert. It is never a pass and never a failure — a suite of 40 cases where 12
39
+ # are skipped says something true, where 40 cases with 12 failures on capability
40
+ # grounds says nothing and gets ignored.
41
+ # `pairwise` (a Pairwise::Verdict, RFC-0014 §3.4) is attached when the case
42
+ # carries a `reference:` and a panel is configured. It DELIBERATELY does not enter
43
+ # `pass?`: "worse than the incumbent" is a judgement about a replacement decision,
44
+ # not a regression in the suite, and letting it fail a case would put an opinion
45
+ # about two conversations in the pre-merge gate.
46
+ CaseResult = Struct.new(:id, :agent, :checks, :error, :rubric, :judge, :skipped, :pairwise,
47
+ keyword_init: true) do
48
+ def skipped? = !skipped.nil?
49
+ def pass? = !skipped? && error.nil? && checks.all?(&:pass) && (judge.nil? || judge.pass)
50
+ def failures = checks.reject(&:pass)
51
+ # Has a rubric to score but no verdict yet (judge disabled / not run). A skipped
52
+ # case is not pending anything — nobody is going to judge a turn that never ran.
53
+ def judge_pending? = !skipped? && !rubric.to_s.strip.empty? && judge.nil?
54
+ end
55
+
56
+ # Deterministic (Fase A) evaluation — cheap, zero-token, zero-flakiness. It's the
57
+ # layer that catches the gross regressions (a tool stopped being called, a secret
58
+ # leaked, the turn errored). Subjective scoring is the LLM-judge in Fase B.
59
+ module Assertions
60
+ # Named negative detectors for `must_not` now live in the runtime (D4). Kept as
61
+ # an alias so any external reference to Evals::Assertions::PII_DETECTORS still
62
+ # resolves; the values ARE the runtime's, never a fork.
63
+ PII_DETECTORS = Insika::Safety::Detectors::PII
64
+
65
+ # HOW MUCH THE AGENT SHOULD ASK BEFORE ACTING (RFC-0014 §3.3). Declared per
66
+ # case because it is a per-STORE decision, not a universal rule: sometimes the
67
+ # agent should establish the objective before searching ("energia, treino ou
68
+ # sono?" — Ocean Drop does this well), and sometimes asking again is the
69
+ # failure and it should just search. A global assertion would be wrong half
70
+ # the time; the judge is TOLD the policy (Judge#build_prompt) and this layer
71
+ # checks the half that needs no reader.
72
+ #
73
+ # Each rule is stated as the CUSTOMER-VISIBLE fact it checks. RFC-0014 phrased
74
+ # this as "questions before the first tool call", written before P19: text a
75
+ # model emits before calling a tool never reaches the customer now (it rides
76
+ # `:intermediate`), and the eval is a client of `/v1/responses`, so what it can
77
+ # observe per turn is the published answer plus the tools that turn called.
78
+ # That is also the honest scope — a question nobody received is not a question.
79
+ POLICIES = {
80
+ # "UMA PERGUNTA POR VEZ" — the rule Insika broke twice under a 28 KB prompt.
81
+ "ask_once" => "at most one question per reply",
82
+ # Establish the objective before acting on a vague opener.
83
+ "investigate_first" => "asks before calling a tool, on the first turn",
84
+ # Act on the first plausible reading; refine after.
85
+ "act_fast" => "calls a tool on the first turn instead of asking"
86
+ }.freeze
87
+
88
+ module_function
89
+
90
+ # WHAT THIS DEPLOYMENT LACKS for the case to be worth running (RFC-0014 §3.2).
91
+ # -> [reason]; empty = run it.
92
+ #
93
+ # `available` is the deployment's answer for this agent:
94
+ # { "tools" => [names] | nil, "capabilities" => [names] }
95
+ # `tools` nil means an OPEN allowlist — the agent may call every registered tool,
96
+ # so no tool requirement can be judged missing and the case runs. That is the
97
+ # deliberate reading: "I could not rule it out" must not become a skip, or a
98
+ # permissive agent would quietly stop being tested.
99
+ def unmet_requirements(golden, available)
100
+ tools = available["tools"]
101
+ declared = Array(available["capabilities"]).map(&:to_s)
102
+
103
+ missing_tools = tools.nil? ? [] : golden.required_tools - Array(tools).map(&:to_s)
104
+ missing_caps = golden.required_capabilities - declared
105
+
106
+ reasons = []
107
+ reasons << "tool not available: #{missing_tools.join(', ')}" unless missing_tools.empty?
108
+ reasons << "capability not declared: #{missing_caps.join(', ')}" unless missing_caps.empty?
109
+ reasons
110
+ end
111
+
112
+ # The case did not run and MUST NOT read as either a pass or a failure.
113
+ def skip(golden, reason)
114
+ CaseResult.new(id: golden.id, agent: golden.agent, checks: [], error: nil,
115
+ rubric: nil, judge: nil, skipped: reason)
116
+ end
117
+
118
+ # A tool status counts as success when it's blank/"ok"/"success" or a 2xx code.
119
+ def ok_status?(status)
120
+ return true if status.nil?
121
+
122
+ s = status.to_s.strip.downcase
123
+ return true if s.empty? || %w[ok success succeeded done].include?(s)
124
+
125
+ code = Integer(s, exception: false)
126
+ code ? code.between?(200, 299) : false
127
+ end
128
+
129
+ # Golden + TurnResult -> CaseResult. A turn that failed to run yields a single
130
+ # failing check (there's nothing to assert on a turn that never produced output).
131
+ #
132
+ # `turns` is every turn of the conversation, in order; `result` is the last one
133
+ # (what the tool/content assertions have always run on). The policy checks need
134
+ # all of them — "one question per reply" is a rule about every reply, and the
135
+ # violation that motivated this was on the FIRST turn. Defaults to the single
136
+ # result so existing callers keep working.
137
+ def evaluate(golden, result, turns: nil)
138
+ if result.error
139
+ return CaseResult.new(id: golden.id, agent: golden.agent, error: result.error, rubric: nil, judge: nil,
140
+ checks: [Check.new(name: "turn", pass: false, detail: "turn error: #{result.error}")])
141
+ end
142
+
143
+ # NOT `Array(turns)`: TurnResult is a Struct, so Array() would explode a single
144
+ # one into its members and hand the policy checks three strings.
145
+ conversation = turns.nil? || turns.empty? ? [result] : turns
146
+ checks = tool_checks(golden, result) + must_not_checks(golden, result) +
147
+ policy_checks(golden, conversation)
148
+ CaseResult.new(id: golden.id, agent: golden.agent, error: nil, checks: checks,
149
+ rubric: golden.rubric, judge: nil)
150
+ end
151
+
152
+ # Each REQUIRED expected tool must appear in the turn's tool calls. Optional
153
+ # ("name?") tools are informational — present or not, they never fail.
154
+ def tool_checks(golden, result)
155
+ names = result.tool_names
156
+ golden.tools_called.filter_map do |t|
157
+ next if t[:optional]
158
+
159
+ present = names.include?(t[:name])
160
+ Check.new(name: "tool:#{t[:name]}", pass: present,
161
+ detail: present ? "called" : "expected but not called (saw: #{names.join(', ')})")
162
+ end
163
+ end
164
+
165
+ # `must_not` detectors. "tool_error" is special (inspects statuses); the rest
166
+ # are content detectors over the output text.
167
+ def must_not_checks(golden, result)
168
+ golden.must_not.map do |name|
169
+ if name == "tool_error"
170
+ bad = result.errored_tools
171
+ Check.new(name: "must_not:tool_error", pass: bad.empty?,
172
+ detail: bad.empty? ? "no tool errors" : "errored: #{bad.map { |t| t['name'] || t[:name] }.join(', ')}")
173
+ else
174
+ hit = detect(name, result.output_text.to_s)
175
+ Check.new(name: "must_not:#{name}", pass: hit.nil?,
176
+ detail: hit ? "matched #{hit.inspect}" : "clean")
177
+ end
178
+ end
179
+ end
180
+
181
+ # The declared `policy`, checked deterministically over the conversation. No
182
+ # policy -> no check (and nothing to explain in the report).
183
+ def policy_checks(golden, turns)
184
+ name = golden.policy
185
+ return [] if name.nil?
186
+
187
+ # Exhaustive on purpose: a policy added to POLICIES without a rule here would
188
+ # otherwise fall into whichever branch was last and check the wrong thing.
189
+ pass, detail = case name
190
+ when "ask_once" then ask_once(turns)
191
+ when "investigate_first" then investigate_first(turns.first)
192
+ when "act_fast" then act_fast(turns.first)
193
+ else raise ArgumentError, "policy #{name.inspect} has no rule"
194
+ end
195
+ [Check.new(name: "policy:#{name}", pass: pass, detail: detail)]
196
+ end
197
+
198
+ # Every reply asks at most one question. Reported with the offending turn and
199
+ # the reply itself — "2 questions" alone sends the reader digging.
200
+ def ask_once(turns)
201
+ offender = turns.each_with_index.find { |t, _| count_questions(t.output_text) > 1 }
202
+ return [true, "at most one question per reply"] unless offender
203
+
204
+ turn, i = offender
205
+ [false, "turn #{i + 1} asked #{count_questions(turn.output_text)} questions: " \
206
+ "#{turn.output_text.to_s.strip[0, 160].inspect}"]
207
+ end
208
+
209
+ def investigate_first(turn)
210
+ return [false, "no turn to check"] if turn.nil?
211
+
212
+ tools = turn.tool_names
213
+ return [false, "called #{tools.join(', ')} before asking anything"] unless tools.empty?
214
+ return [false, "answered without asking: #{turn.output_text.to_s.strip[0, 160].inspect}"] if
215
+ count_questions(turn.output_text).zero?
216
+
217
+ [true, "asked before acting"]
218
+ end
219
+
220
+ def act_fast(turn)
221
+ return [false, "no turn to check"] if turn.nil?
222
+
223
+ tools = turn.tool_names
224
+ return [true, "acted: called #{tools.join(', ')}"] unless tools.empty?
225
+
226
+ [false, "asked instead of acting: #{turn.output_text.to_s.strip[0, 160].inspect}"]
227
+ end
228
+
229
+ # Questions in ONE reply. Deliberately crude and deliberately documented: a run
230
+ # of "?" counts once ("já pensou??" is one question), and URLs are dropped first
231
+ # so a tracking link's query string is not read as the agent asking something.
232
+ # It is a policy signal, not grammar — and it already caught a real violation
233
+ # ("é pra você ou tá pensando em presentear alguém? E qual seu tamanho?").
234
+ def count_questions(text)
235
+ text.to_s.gsub(%r{https?://\S+}, " ").scan(/\?+/).size
236
+ end
237
+
238
+ # Runs a named detector over the text. "pii_leak" = union of all PII detectors;
239
+ # otherwise a single named pattern. Delegates to the runtime's single source
240
+ # (D4) — which itself fails loud on an unknown name (a typo'd assertion must not
241
+ # silently pass).
242
+ def detect(name, text)
243
+ Insika::Safety::Detectors.detect(name, text)
244
+ end
245
+ end
246
+ end
247
+ end