insika 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
data/docs/domain.md ADDED
@@ -0,0 +1,115 @@
1
+ ---
2
+ title: The domain-free core
3
+ parent: Operate & prove it
4
+ nav_order: 9
5
+ permalink: /domain/
6
+ ---
7
+
8
+ # The domain-free core — what ships, what a deployment declares, and how to clear it
9
+
10
+ The engine is domain-free by construction (RFC-0036): the gem carries no store
11
+ vocabulary, no persona, and no fixed conversation language. This page is the
12
+ removability map — for every artifact that could make a deployment look like
13
+ "the Brazilian store harness", here is what ships, what the doctor reports, and
14
+ how to clear it.
15
+
16
+ ## What ships and what does not
17
+
18
+ One selection function owns the gem payload: `Insika::Packaging.payload_files`
19
+ — the gemspec and the audit spec (`spec/insika/domain_boundary_spec.rb`) call
20
+ the same function, so the boundary is a fact the suite asserts on, never a
21
+ prose promise. It ships:
22
+
23
+ - `lib/` — the engine, the server, the Studio (the compiled JS only);
24
+ - `docs/` — the public documentation;
25
+ - `README.md`, `LICENSE`, `CHANGELOG.md`, `bin/insika`.
26
+
27
+ It never ships: `deploy/`, `packs/`, `examples/`, `plugins/`, `evals/`,
28
+ `scripts/`, `spec/` — even a tracked pack cannot ship. Packs, personas and
29
+ deployment wiring are checkout material, and that is the point: the reference
30
+ deployment is not the gem.
31
+
32
+ The proof command is `insika doctor --domain`: a read-only inventory of what a
33
+ deployment declares, plus the built-in corpora still in effect. A bare install
34
+ with no agents names nothing; an install that boots an agent reports the
35
+ built-in pt-BR guardrail corpus as `source: gem-default` — the removability
36
+ surface, not a store. The section never fails the doctor's exit code: it is
37
+ informational, the RFC's proof surface.
38
+
39
+ ## The guardrail corpus — clear the shipped pt-BR content
40
+
41
+ The deterministic guardrail patterns ship as language-tagged data
42
+ (`Safety::Corpus`). A deployment clears a language by config, per agent:
43
+
44
+ ```jsonc
45
+ // pack: agent.config.json
46
+ { "guardrails": { "corpora": { "languages": ["en"] } } }
47
+ ```
48
+
49
+ ```ruby
50
+ # DSL
51
+ agent = Insika.agent("store-support") do
52
+ instructions "…"
53
+ guardrails corpora: { languages: ["en"] }
54
+ end
55
+ ```
56
+
57
+ - `languages` filters the shipped families: `nil` = all (the default),
58
+ `[]` = none, `["en"]` = the EN-only corpus. An EN-only deployment drops the
59
+ pt-BR input heuristics **and** the CPF/CNPJ output redaction — a documented
60
+ consequence: the tax-id formats are pt-BR data, and the universal "secret"
61
+ redaction is never cleared.
62
+ - `extra` adds source-string patterns per family:
63
+
64
+ ```jsonc
65
+ { "guardrails": { "corpora": { "extra": { "abuse": ["/\\bdupa\\b/i"] } } } }
66
+ ```
67
+
68
+ - `guardrails.responses` replaces the built-in pt-BR fallback replies (the
69
+ safe refusals). Set a `default` to clear every category at once.
70
+
71
+ See [Security](SECURITY.md) for the guardrail layers; the doctor's domain
72
+ section enumerates any agent still running the built-in pt-BR corpus or the
73
+ built-in replies, with the clear path in each entry. An unknown language or
74
+ family, or a malformed pattern, is refused at boot — `insika doctor` reports it
75
+ as an error and the agent never turns with a broken corpus.
76
+
77
+ ## Marking a deployment — declare, never infer
78
+
79
+ The engine never guesses a store. Domain markers are data a deployment
80
+ declares, and the doctor reads only those declarations:
81
+
82
+ - **Personas/packs** — `metadata.domain` on the agent profile:
83
+
84
+ ```jsonc
85
+ { "metadata": { "domain": "e-commerce-pt-BR" } }
86
+ ```
87
+
88
+ ```ruby
89
+ metadata domain: "e-commerce-pt-BR"
90
+ ```
91
+
92
+ - **Outcome funnel** — `funnel:` on the profile (see
93
+ [Outcomes](AGENTS.md#outcomes--business-results-over-real-traffic-ws7)).
94
+ Vocabulary note: in the gem and the doctor output it is an **outcome
95
+ funnel**, never "conversion" — the stage names are the deployment's, and a
96
+ bare install shows no funnel and no stage names at all.
97
+ - **Evidence** — the `evidence:` declaration on a tool manifest (see
98
+ [Tools](TOOLS.md)); the kinds are the deployment's vocabulary, never gem
99
+ constants.
100
+
101
+ `insika doctor --domain` enumerates all four with their source —
102
+ `deployment` for declared data, `gem-default` for the built-in corpus still in
103
+ effect — and a bare boot names none.
104
+
105
+ ## The conformance claim — model-visible means logged
106
+
107
+ "Every byte that reaches the provider is reconstructable from checkpoints +
108
+ traces" is a spec, not a promise. For each (task, turn) the engine records the
109
+ model-visible payload at the provider boundary — the system text, the tool
110
+ schemas, and the full message stream (`ModelVisibleTraceStore`) — next to the
111
+ durable transcript (the checkpoint). The conformance suite
112
+ (`spec/insika/conformance/model_visible_spec.rb`) drives real turns on a
113
+ capturing chat and asserts a three-way byte identity: what the chat held ==
114
+ the checkpoint transcript == the model trace. A path whose bytes are not
115
+ logged is a fix in the engine, never a waiver in the suite.
data/docs/index.md CHANGED
@@ -34,9 +34,9 @@ Ruby `>= 3.3` and a provider key (the demo uses DeepSeek). The whole program:
34
34
  require "insika"
35
35
 
36
36
  assistant = Insika.agent("assistant") do
37
- model "deepseek-chat"
37
+ model "deepseek-v4-flash"
38
38
  provider :deepseek
39
- instructions "You are Bia, a concise and friendly assistant. Answer briefly."
39
+ instructions "You are a concise and friendly assistant. Answer briefly."
40
40
  end
41
41
 
42
42
  puts assistant.reply("hi, what can you do?") # one turn, in-process
@@ -51,7 +51,7 @@ require "insika"
51
51
 
52
52
  assistant = Insika.agent("assistant") do
53
53
  provider :deepseek # ← the provider slug from {{MODELS_URL}}
54
- model "deepseek-chat" # ← a model id from {{MODELS_URL}}
54
+ model "deepseek-v4-flash" # ← a model id from {{MODELS_URL}}
55
55
  instructions "You are a concise, friendly assistant. Answer briefly."
56
56
  end
57
57
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "coercion"
4
+ require_relative "tool_definition"
4
5
 
5
6
  module Insika
6
7
  # Single point of per-agent policy.
@@ -16,11 +17,24 @@ module Insika
16
17
  :id, :model, :provider,
17
18
  :base_prompt, :prompt_files,
18
19
  :tools_allow, :tools_deny,
19
- :tools_allow_groups, # per-GROUP allowlist (Phase 7/D4/F5, Stage C):
20
+ :tools_allow_groups, # per-GROUP allowlist:
20
21
  # union with tools_allow; deny wins; both
21
22
  # nil = all (parity). Expands to the group's
22
23
  # tools in the ToolAllowlist policy.
23
24
  :skills,
25
+ :skills_eager, # progressive disclosure OFF, wholly or in part:
26
+ # nil/false = level 1 + load_skill (parity); true = every
27
+ # allowed skill; [names] = exactly these. An eager skill's
28
+ # BODY enters the prompt each turn, it leaves the
29
+ # <available_skills> catalog and load_skill refuses it.
30
+ # Removes the activation DECISION (no miss rate) at the
31
+ # cost of the bodies' tokens — measure them against
32
+ # context_budget before turning it on. Same opt-in as
33
+ # `memory`. It lives HERE and not in the SKILL.md
34
+ # frontmatter because skills are shared between agents:
35
+ # a per-skill flag forced one decision onto every
36
+ # allowlist holding the skill. NOT `Allowlist`
37
+ # semantics — nil means NONE here (SkillCatalog#eager_for).
24
38
  :context_providers, # provider allowlist
25
39
  :workflows_allow, # applied by WorkflowAllowlist
26
40
  :policies, # names in the Policy Registry
@@ -30,7 +44,7 @@ module Insika
30
44
  :capabilities, # intents the agent can trigger.
31
45
  # nil = NONE (opt-in, see above).
32
46
  :subagents, # allowlist of child agent ids this agent MAY spawn
33
- # (RFC-0010, item 21). CAPACITY field — NEVER inherits;
47
+ # CAPACITY field — NEVER inherits;
34
48
  # opt-in like `capabilities`: nil/absent = NONE (do NOT
35
49
  # "fix" to nil = all). Present => the `spawn_subagent`
36
50
  # system tool is wired (ChatBuilder), gated by this set.
@@ -40,7 +54,7 @@ module Insika
40
54
  :memory, # cross-session memory.
41
55
  # nil/false = OFF (parity: provider []; the `remember`
42
56
  # tool not wired); true = ON. Same opt-in as capabilities.
43
- :prompt_caching, # Anthropic prompt caching (§11 R3): nil/false = OFF
57
+ :prompt_caching, # Anthropic prompt caching (R3): nil/false = OFF
44
58
  # (parity); true = ON. Same opt-in as `memory`. When ON
45
59
  # AND the resolved provider is Anthropic, ChatBuilder sets
46
60
  # ONE cache breakpoint at the end of the system block
@@ -50,34 +64,108 @@ module Insika
50
64
  # a context provider injecting volatile content into
51
65
  # :system turns every turn into a paid cache WRITE with
52
66
  # no read hit. Enable only for stable-system agents.
53
- :params, # LLM generation params (v2, §10): a Hash with
67
+ :tool_persistence, # the engine's "Tool discipline" block in the system
68
+ # prompt (retry weak/empty tool results with a different
69
+ # approach before giving up). THE ONE OPT-OUT FIELD:
70
+ # nil/true = ON (the proven default — every reference
71
+ # harness ships it), false = OFF. Deliberately inverted
72
+ # from the opt-in fields above: the exception here is
73
+ # turning the good behavior OFF, so that is what an
74
+ # operator declares. Read by Context::Providers::Prompt.
75
+ :tool_output_compression, # MECHANICAL tool-result dedupe in the replayed
76
+ # history (A3/C3): nil/false = OFF (parity); true = ON.
77
+ # Same opt-in as `memory`. When ON, the history the
78
+ # Session provider seeds replaces byte-identical repeated
79
+ # tool results with a compact back-reference (first
80
+ # occurrence stays full) — no LLM involved. CHANGES WHAT
81
+ # THE MODEL SEES: an older full result is only the first
82
+ # occurrence; a model that wants an older detail re-calls
83
+ # the tool. Cheap half of compaction for bloated histories.
84
+ :params, # LLM generation params: a Hash with
54
85
  # temperature/max_tokens/thinking, applied to the chat at
55
86
  # stage 5. {} = provider defaults (parity).
56
- :model_policy, # governance of WHICH models the agent may use (v2, §10):
87
+ :budget, # spend caps per (tenant, agent) over
88
+ # CALENDAR windows (WS2): { "daily" => int,
89
+ # "monthly" => int, "soft" => bool, "alert_at" => 0.8 }.
90
+ # HARD is the default: absent/"soft": false, a turn
91
+ # arriving at/over the cap fails with Insika::BudgetExceeded
92
+ # and the envelope quotes `budget_exceeded` + retry_after.
93
+ # "soft": true crosses the cap and still runs
94
+ # (one budget_warning event per window + a note in the
95
+ # context).
96
+ # Tokens count the billed spend (input+output+cached+
97
+ # cache_creation). nil/absent = no budget (parity).
98
+ :reliability, # the provider-interaction reliability policy (WS3):
99
+ # { "retries" => 3, "backoff" => "exponential",
100
+ # "fallback" => ["gpt-4o-mini", ...],
101
+ # "circuit_breaker" => { "after" => 10, "within" => 60,
102
+ # "cooldown" => 300 }, "timeout" => 30 }. Data, never
103
+ # DSL: retries + exponential backoff on :retryable /
104
+ # :rate_limited_* failures (never :fatal), mid-turn
105
+ # rotation across the fallback chain (profile's first,
106
+ # then the platform's resolved fallbacks), and a circuit
107
+ # breaker per (tenant, provider/model) that fail-fasts
108
+ # with circuit_open + retry_after once the window count
109
+ # trips. nil/absent = the plain single attempt (parity).
110
+ :alerts, # operator alert delivery (WS6): { "webhook" => url }.
111
+ # When present, the agent's budget_warning /
112
+ # breaker_open / delivery_failed events are POSTed to
113
+ # the URL as JSON (outbox + claim, at-most-once).
114
+ # nil/absent = no webhook (parity).
115
+ :routes, # intent routing (WS4): { "route" => "description" or a
116
+ # Hash with description/delegate/stuck/message,
117
+ # "default" => route, "model" => cheap classifier }.
118
+ # When present, the message is classified BEFORE the
119
+ # ask with the cheap model; the route lands on the
120
+ # turn (state.route + :route_classified) and may
121
+ # delegate to an existing agent or end the turn :stuck
122
+ # (WS5). nil/absent = no routing (parity).
123
+ :stuck_signal, # the agent may signal it cannot proceed (WS5):
124
+ # nil/false = OFF (parity — the signal_stuck system
125
+ # tool is not wired); true = ON (the model may call
126
+ # signal_stuck, which ends the turn with
127
+ # `outcome: :stuck` + a final message + a :turn_stuck
128
+ # event the consumer acts on). Same opt-in as
129
+ # `memory`. What "stuck" MEANS is the consumer's call
130
+ # (escalation via CRM/operator), never the engine's.
131
+ :outputs, # generated-media output policy (WS9, saída):
132
+ # { "image" => { "model" => …, "size" => "1024x1024" },
133
+ # "tts" => { "model" => "tts-1", "voice" => "alloy",
134
+ # "format" => "mp3" } }. THE AGENT'S HALF of the
135
+ # media-output gate — nil/absent = the agent never
136
+ # generates media (opt-in like `capabilities`, do NOT
137
+ # "fix" to nil = all). The other half is the CHANNEL'S:
138
+ # the request must declare it can receive the media
139
+ # (`channel.capabilities` — "image_output" /
140
+ # "audio_output"); only with BOTH does the model see
141
+ # the generate_image/tts tools (the abstraction admits
142
+ # only what leaks). Generated media rides the turn's
143
+ # `output_parts` in the envelope, never the answer text.
144
+ :model_policy, # governance of WHICH models the agent may use:
57
145
  # { "allow" => [refs] }. nil = NO fence (all models —
58
146
  # parity). Enforced on the RESOLVED model (ModelResolver).
59
- :guardrails, # content-safety config (RFC-0009 §3.3): { input:, output:,
147
+ :guardrails, # content-safety config: { input:, output:,
60
148
  # moderator:, strictness: }. OPT-IN like capabilities —
61
149
  # nil/absent = the conservative default (Safety::Config:
62
150
  # deterministic on, moderator off). Parsed, never a policy.
63
- :sandbox, # confined-execution config (item 35, §4.6):
151
+ :sandbox, # confined-execution config:
64
152
  # { provider: "local"|"docker", root:, timeout:, ...+provider
65
153
  # keys }. Declarative provider selection (config-over-code) —
66
154
  # consumed by Insika::Sandbox.build. {} = absent (a
67
155
  # deployment builds a `local` sandbox by default). It is
68
156
  # CONFIG, never a policy — it does not decide security by
69
157
  # itself; the FS boundary + approvals do.
70
- :refinement, # self-improvement config (RFC-0013 §3.8):
158
+ :refinement, # self-improvement config:
71
159
  # { mode: "report"|"propose"|"auto_apply", window: {…},
72
160
  # files: [allowlist], proposers: [refs], budget: {tokens:},
73
161
  # auto_apply_max_edits:, max_findings:, … }. nil/absent =
74
- # REPORT-ONLY (phase A writes nothing to the agent, so
162
+ # REPORT-ONLY (writes nothing to the agent, so
75
163
  # reading your own traces needs no opt-in); `propose`
76
164
  # and above must be enabled explicitly. It is CONFIG,
77
165
  # never a policy — the write allowlist it carries is
78
166
  # enforced by the applier, not by this field.
79
167
  :capabilities_declared, # FACTS ABOUT THIS DEPLOYMENT that are not tools
80
- # (RFC-0014 §3.5): %w[promotions human_handoff
168
+ # %w[promotions human_handoff
81
169
  # b2b_pricing]. An eval case declares what it
82
170
  # `requires` and is SKIPPED — never failed — where
83
171
  # the deployment lacks it, which is what makes one
@@ -104,10 +192,72 @@ module Insika
104
192
  # (WhatsApp) puts the deliberation in front of a
105
193
  # customer; that is the operator's call to make, not a
106
194
  # default to inherit.
107
- :metadata # free-form agent metadata, stable per agent
108
- # (from the pack `agent.config.json`). Home of the `store_id`
109
- # that becomes turn context (ctx.store_id, Phase 6/D2).
110
- # It is NOT a policy — never decides security. {} = absent.
195
+ :grounding, # the pack's grounding policy —
196
+ # { "mode" => "flag"|"enforce"|"off",
197
+ # "matcher" => { "sku" => …,
198
+ # "name_keys" => [...] } }. OPT-IN:
199
+ # nil/absent = OFF (parity, zero allocations).
200
+ # Deep-stringified like the other hashes.
201
+ :metadata, # free-form agent metadata, stable per agent
202
+ # (from the pack `agent.config.json`). Home of the `store_id`
203
+ # that becomes turn context (ctx.store_id).
204
+ # It is NOT a policy — never decides security. {} = absent.
205
+ :briefing_fields, # the per-session working-state schema this agent
206
+ # keeps and asks for: a flat [String] of
207
+ # field names the pack declares. []/nil/absent = the
208
+ # feature is OFF (no provider output, no tools — visibly
209
+ # removable). Names are engine-owned store keys
210
+ # and tool text, so they are validated against NAME_RE at
211
+ # build time. Data, never a policy: the engine owns the
212
+ # briefing object, the pack owns the fields.
213
+ :funnel, # the outcome funnel declaration — pack
214
+ # data, exactly like budget/reliability:
215
+ # { "stages" => ["greeted", "qualified", "cart", "paid"],
216
+ # "advance_on" => { "pix_paid" => "paid", … },
217
+ # "primary" => "paid", "attribution_window" => "72h" }.
218
+ # The ENGINE never hard-codes a stage name: the fold,
219
+ # the doctor and the Studio read this declaration (D1).
220
+ # nil/absent = no funnel (parity — nothing folds).
221
+ # Deep-stringified like the other free-form hashes;
222
+ # shape-validated by FunnelDeclaration, never here (D8).
223
+ :followup, # the follow-up declaration — pack data,
224
+ # exactly like budget/funnel:
225
+ # { "arm" => "schedule",
226
+ # "policy" => { "quiet_hours" => { "timezone" => "…",
227
+ # "start" => "21:30", "end" => "09:00" },
228
+ # "max_frequency" => "2/24h",
229
+ # "cancel_keywords" => ["não quero mais contato"],
230
+ # "silence_after_sends" => 3 } }.
231
+ # The engine OWNS the firing, never a policy value (D1);
232
+ # shape-validated by FollowupPolicy, never here (D9).
233
+ # nil/absent = the feature is off (parity).
234
+ # Deep-stringified like the other free-form hashes.
235
+ :distill, # the session-distillation declaration — pack
236
+ # data, exactly like refinement/followup:
237
+ # { "enabled" => bool, "prompt" => "<pack-authored markdown
238
+ # — what counts as a fact for this store>",
239
+ # "model" => "<ref — absent = the platform utility_model>",
240
+ # "idle_hours" => 6, "min_messages" => 3,
241
+ # "max_proposals" => 10 }.
242
+ # The ENGINE assembles the scope from the session; the
243
+ # model only names facts (D1). nil/absent = the feature is
244
+ # off (parity, byte-identical engine). Shape-validated by
245
+ # the command/engine, never here (the refinement precedent).
246
+ # Deep-stringified like the other free-form hashes.
247
+ :harvest # the gated-harvest declaration — pack data,
248
+ # exactly like refinement/distill:
249
+ # { "enabled" => bool,
250
+ # "negative_list" => [ { "rule" => "…", "pattern" => "…",
251
+ # "note" => "…" } ],
252
+ # "miner" => { "model" => "<ref — absent = the platform
253
+ # utility_model>", "window" => { "last_sessions" => N },
254
+ # "max_proposals" => N, "budget" => { "tokens" => N } },
255
+ # "idle_hours" => 24, "min_messages" => 3 }.
256
+ # The ENGINE mines (reads sessions, asks the miner, filters
257
+ # through the negative list + grounding), never authors a
258
+ # rule (D4). nil/absent = the loop is off (parity).
259
+ # Shape-validated by the command/engine/doctor, never here.
260
+ # Deep-stringified like the other free-form hashes.
111
261
  )
112
262
 
113
263
  # Reopened class (not a Data.define block): a constant assigned inside
@@ -116,29 +266,36 @@ module Insika
116
266
  DEFAULT_LIMITS = {
117
267
  turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
118
268
  context_budget: 8_000, max_tool_calls: 50,
269
+ # consecutive identical (tool, args) calls that trigger the ONE
270
+ # loop warning; a repeat after it aborts like max_tool_calls. < 2 = off.
271
+ max_tool_repeat: 3,
119
272
  approval_timeout: 3_600, # cap on the wait for human approval (~1h)
120
- # Item 30: parallel tool calls. ONE number is both the switch and the cap
273
+ # parallel tool calls. ONE number is both the switch and the cap
121
274
  # (nil/0/1 = serial, the default; N > 1 = at most N tool calls in flight).
122
275
  # It sits next to tool_timeout/max_tool_calls because it is the third bound
123
276
  # on tool execution. Read through TurnState#tool_concurrency, which also
124
- # applies the approval gate (D3).
277
+ # applies the approval gate.
125
278
  tool_concurrency: 1
126
279
  }.freeze
127
280
 
128
- # `model` is OPTIONAL as of v2 (§10): an agent without one resolves the
281
+ # `model` is OPTIONAL as of v2: an agent without one resolves the
129
282
  # platform `default_model` (Settings) at turn start via the ModelResolver.
130
283
  def self.build(id:, model: nil, provider: nil, base_prompt: "", prompt_files: [],
131
284
  tools_allow: nil, tools_deny: [], tools_allow_groups: nil, skills: nil,
132
- context_providers: nil, workflows_allow: nil,
285
+ skills_eager: nil, context_providers: nil, workflows_allow: nil,
133
286
  policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
134
287
  capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
135
- prompt_caching: nil,
136
- params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
137
- refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {})
288
+ prompt_caching: nil, tool_persistence: nil, tool_output_compression: nil,
289
+ params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
290
+ refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
291
+ budget: nil, reliability: nil, alerts: nil, routes: nil, stuck_signal: nil,
292
+ outputs: nil, briefing_fields: nil, grounding: nil, funnel: nil,
293
+ followup: nil, distill: nil, harvest: nil)
138
294
  new(
139
295
  id: id, model: model, provider: provider, base_prompt: base_prompt,
140
296
  prompt_files: Array(prompt_files), tools_allow: tools_allow,
141
297
  tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
298
+ skills_eager: skills_eager,
142
299
  context_providers: context_providers, workflows_allow: workflows_allow,
143
300
  policies: Array(policies), prompt_refs: Array(prompt_refs),
144
301
  limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
@@ -147,7 +304,8 @@ module Insika
147
304
  # readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
148
305
  subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
149
306
  tools_deferred: tools_deferred, memory: memory,
150
- prompt_caching: prompt_caching,
307
+ prompt_caching: prompt_caching, tool_persistence: tool_persistence,
308
+ tool_output_compression: tool_output_compression,
151
309
  # The free-form hashes arrive with symbol keys (internal build) OR string
152
310
  # keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
153
311
  # here — the single front door every profile passes through — so no reader
@@ -161,10 +319,54 @@ module Insika
161
319
  # symbol/string mix there would be a silent miss.
162
320
  capabilities_declared: Array(capabilities_declared).map(&:to_s),
163
321
  edge_stream: Coercion.deep_stringify(edge_stream || {}),
164
- metadata: Coercion.deep_stringify(metadata || {})
322
+ metadata: Coercion.deep_stringify(metadata || {}),
323
+ budget: Coercion.deep_stringify(budget),
324
+ reliability: Coercion.deep_stringify(reliability),
325
+ alerts: Coercion.deep_stringify(alerts),
326
+ routes: Coercion.deep_stringify(routes),
327
+ stuck_signal: stuck_signal,
328
+ outputs: Coercion.deep_stringify(outputs),
329
+ # Flat [String] — same discipline as capabilities_declared: a
330
+ # symbol/string mix would be a silent miss in the provider's known-set.
331
+ briefing_fields: normalize_briefing_fields(briefing_fields),
332
+ # grounding is profile DATA, deep-stringified like the other
333
+ # free-form hashes; parsed into a Grounding per turn by the validator/
334
+ # enforcer. nil = off (parity).
335
+ grounding: Coercion.deep_stringify(grounding),
336
+ # funnel is profile DATA, deep-stringified like the other
337
+ # free-form hashes; parsed into a FunnelDeclaration by the fold/doctor/
338
+ # Studio (shape-validated THERE, never here — D8). nil = no funnel (parity).
339
+ funnel: Coercion.deep_stringify(funnel),
340
+ # followup is profile DATA, deep-stringified like the other
341
+ # free-form hashes; parsed into a FollowupPolicy by the tool/engine/
342
+ # doctor/Studio (shape-validated THERE, never here — D9). nil = off (parity).
343
+ followup: Coercion.deep_stringify(followup),
344
+ # distill is profile DATA, deep-stringified like the other
345
+ # free-form hashes; shape-validated by the command/engine/doctor
346
+ # (never here — the refinement precedent). nil = off (parity).
347
+ distill: Coercion.deep_stringify(distill),
348
+ # harvest is profile DATA, deep-stringified like the other
349
+ # free-form hashes; shape-validated by the command/engine/doctor
350
+ # (never here — the refinement precedent). nil = off (parity).
351
+ harvest: Coercion.deep_stringify(harvest)
165
352
  )
166
353
  end
167
354
 
355
+ # nil -> []; strings; trim + drop empties + uniq (stable order); every name
356
+ # must match ToolDefinition::NAME_RE (\A[a-z][a-z0-9_]*\z) or it is a
357
+ # ValidationError at build time — the names become tool-description text,
358
+ # store keys and context-block lines, so "size ok" or "tamanho do cliente"
359
+ # is refused here, not corrupted later.
360
+ def self.normalize_briefing_fields(list)
361
+ names = Array(list).map { |f| f.to_s.strip }.reject(&:empty?).uniq
362
+ bad = names.reject { |n| ToolDefinition::NAME_RE.match?(n) }
363
+ unless bad.empty?
364
+ raise Insika::ValidationError,
365
+ "briefing_fields must match #{ToolDefinition::NAME_RE.inspect}: #{bad.join(', ')}"
366
+ end
367
+ names
368
+ end
369
+
168
370
  # opt-in for an optional tool = being in the agent's allow list.
169
371
  def tool_opted_in?(name)
170
372
  Array(tools_allow).include?(name)
@@ -173,8 +375,8 @@ module Insika
173
375
  # store_id of the turn context (ctx.store_id): lives in `metadata` (stable
174
376
  # per store, comes from the pack). `build` string-keys metadata, so a plain
175
377
  # string lookup is enough. nil = absent (the data-tool emits an empty header).
176
- # It is NOT achei-specific: `store_id` is a field of the turn-context contract
177
- # (§5), generic per project.
378
+ # It is NOT consumer-specific: `store_id` is a field of the turn-context contract
379
+ # generic per project.
178
380
  def store_id = (metadata || {})["store_id"]
179
381
 
180
382
  # May this channel (:thinking / :intermediate) cross to the customer? Tolerant
@@ -182,7 +384,7 @@ module Insika
182
384
  # of anything else being absent: the safe reading is the default one.
183
385
  def stream_public?(channel)
184
386
  v = (edge_stream || {})[channel.to_s]
185
- [true, "true", "1", "yes", "on"].include?(v)
387
+ Coercion.truthy?(v)
186
388
  end
187
389
  end
188
390
  end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Operator alerts to a webhook (WS6): the events `:budget_warning`,
8
+ # `:breaker_open` and `:delivery_failed` are answered per AGENT — a profile
9
+ # declaring `alerts: { "webhook" => url }` gets its alerts POSTed there as
10
+ # JSON. The delivery reuses the outbox + claim mechanism whole
11
+ # (`ChannelDelivery`): the handler only WRITES the outbox row; the existing
12
+ # tick sweep and boot recovery claim and POST it, at-most-once with bounded
13
+ # retry, via a registered `Channels::Webhook`. The engine transports the event
14
+ # and does not interpret it — a Slack/CRM adapter is the consumer's.
15
+ #
16
+ # Started as a child of the turn supervisor (like the tick) in serving mode;
17
+ # tests drive `handle` directly.
18
+ class AlertDispatcher
19
+ ALERT_TYPES = %i[budget_warning breaker_open delivery_failed].freeze
20
+
21
+ def initialize(event_stream:, outbox:, channels:, profiles:, task_store: nil, http:)
22
+ @event_stream = event_stream
23
+ @outbox = outbox
24
+ @channels = channels
25
+ @profiles = profiles
26
+ @task_store = task_store
27
+ @http = http
28
+ @webhook_ids = {} # url -> registered channel id (one webhook per URL)
29
+ # WS6 (boot recovery): webhook channels are derived from PROFILE config,
30
+ # not from events. Registering lazily (on the first alert) means a pending
31
+ # outbox row a crashed process left is swept at boot against an EMPTY
32
+ # registry and marked failed terminal. Pre-registering every configured
33
+ # URL at wiring time lets the boot sweep find the channel and deliver.
34
+ register_all_webhooks
35
+ end
36
+
37
+ # Serving: a long-lived consumer that answers every alert event. Drains on
38
+ # the supervisor fiber (blocks on the queue — no spin), exactly like the tick.
39
+ # It subscribes TYPED (only the alert events enter its queue — it answers
40
+ # payloads a full-traffic stream would otherwise overflow away) and, on an
41
+ # overflow close, RE-SUBSCRIBES: a consumer that never re-binds is how alerts
42
+ # stop in silence (WS6).
43
+ def start(parent:)
44
+ parent.async do |t|
45
+ t.annotate("insika-alerts")
46
+ loop do
47
+ subscription = @event_stream.subscribe(types: ALERT_TYPES)
48
+ subscription.each { |event| handle(event) }
49
+ # the subscription closed (its overflow path) — alerts must not die here
50
+ end
51
+ end
52
+ true
53
+ end
54
+
55
+ # The event -> outbox row. Cheap (one transactional write); the DELIVERY is
56
+ # the tick's job. Never raises: an alerting failure must not break the turn.
57
+ def handle(event)
58
+ type = event.type.to_s.to_sym
59
+ return unless ALERT_TYPES.include?(type)
60
+
61
+ agent = agent_for(event)
62
+ return if agent.nil?
63
+
64
+ profile = @profiles.respond_to?(:fetch) ? @profiles.fetch(agent.to_s) : nil
65
+ return if profile.nil?
66
+
67
+ url = profile&.respond_to?(:alerts) ? profile.alerts&.dig("webhook") : nil
68
+ return if Coercion.blank?(url)
69
+
70
+ record_alert(agent: agent.to_s, url: url.to_s, event: event)
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ private
76
+
77
+ # The agent the alert belongs to: the event carries it for the alerts the
78
+ # engine emits with context (budget_warning / breaker_open); a
79
+ # delivery_failed resolves its task's command. Guards the loop: a webhook's
80
+ # OWN delivery failing is not re-alerted.
81
+ def agent_for(event)
82
+ case event.type.to_sym
83
+ when :delivery_failed
84
+ channel = event.data[:channel]
85
+ return nil if channel.to_s.start_with?("webhook:") # loop guard
86
+ agent_for_task(event.meta[:task_id])
87
+ else
88
+ event.data[:agent] || agent_for_task(event.meta[:task_id])
89
+ end
90
+ end
91
+
92
+ def agent_for_task(task_id)
93
+ return nil if task_id.nil? || @task_store.nil?
94
+
95
+ task = @task_store.find(task_id.to_s)
96
+ command = task&.respond_to?(:command) ? task.command : nil
97
+ return nil unless command.is_a?(Hash)
98
+
99
+ payload = command["payload"] || command[:payload] || {}
100
+ payload["agent"] || payload[:agent]
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ # The event's durable record, as the CHANNEL would see it. `to` is the
106
+ # webhook URL; `payload` is the event itself (type/data/meta).
107
+ def record_alert(agent:, url:, event:)
108
+ channel = webhook_id(url)
109
+ @outbox.create(
110
+ channel: channel, to: url,
111
+ task_id: event.meta[:task_id], session_id: event.meta[:session_id],
112
+ payload: { "type" => event.type.to_s, "data" => event.data,
113
+ "meta" => event.meta, "agent" => agent }
114
+ )
115
+ end
116
+
117
+ # One channel per URL, registered so ChannelDelivery.sweep can claim it.
118
+ def webhook_id(url)
119
+ @webhook_ids[url] ||= begin
120
+ id = "webhook:#{Digest::SHA1.hexdigest(url)[0, 8]}"
121
+ @channels.register(id, Channels::Webhook.new(url, http: @http))
122
+ id
123
+ end
124
+ end
125
+
126
+ # Boot face of `webhook_id`: register every configured URL up front (at
127
+ # wiring time, before the boot recovery's channel sweep runs). The url is
128
+ # PROFILE data, so it is known before any alert ever fires.
129
+ def register_all_webhooks
130
+ profiles = @profiles.respond_to?(:all) ? @profiles.all : []
131
+ profiles.each do |profile|
132
+ next unless profile&.respond_to?(:alerts)
133
+
134
+ url = profile.alerts&.dig("webhook")
135
+ webhook_id(url.to_s) unless Coercion.blank?(url)
136
+ end
137
+ end
138
+ end
139
+ end