insika 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # Per-agent workspace. Holds the
7
+ # CONTENT of each agent's prompt files (IDENTITY.md/SOUL.md/TOOLS.md
8
+ # and the like) in the durable Store — not on disk. It is what makes "each one
9
+ # builds its own BIA with its own identity": the Prompt provider reads from
10
+ # here, per agent, instead of the fixed `files:` in the wiring (which are now
11
+ # only the deployment default).
12
+ #
13
+ # One record per agent in the ConfigStore (scope "agent_files"):
14
+ # { "files" => { "<name>" => { "content" => str,
15
+ # "updated_at" => iso8601,
16
+ # "history" => [ { "content" => str, "at" => iso8601 }, ... ] } } }
17
+ #
18
+ # A write versions: the previous content goes into `history` (most recent
19
+ # first), capped at HISTORY_MAX — restoring becomes a new write
20
+ # (preserves the linear history, no destructive "time travel").
21
+ class AgentFileStore
22
+ SCOPE = "agent_files"
23
+ HISTORY_MAX = 20
24
+
25
+ def initialize(config_store:)
26
+ @cs = config_store
27
+ end
28
+
29
+ # -> String | nil (current content; nil = nonexistent file/agent).
30
+ def read(agent_id, filename)
31
+ entry(agent_id, filename.to_s)&.fetch("content", nil)
32
+ end
33
+
34
+ # -> [String] the agent's file names, lexicographic order.
35
+ def list(agent_id)
36
+ files(agent_id).keys.sort
37
+ end
38
+
39
+ # -> [String] every agent that has a workspace here (what `doctor` sweeps).
40
+ def agents = @cs.keys(SCOPE).sort
41
+
42
+ # A prompt file is TEXT. `to_s` on a structured value produces Ruby's `#inspect`
43
+ # and stores it as if it were the prompt: the pilot lost all 11 files of its
44
+ # production agent that way (someone wrote an ENTRY back as content, so the
45
+ # markdown reached the model as `{"content" => "…\n…"}` on a single line, escapes
46
+ # and all, for three weeks). Nothing legitimate passes a Hash or an Array here, so
47
+ # this is the one place that has to refuse instead of coerce.
48
+ def self.text!(content, agent_id, filename)
49
+ return content if content.is_a?(String)
50
+ return content.to_s unless content.is_a?(Hash) || content.is_a?(Array)
51
+
52
+ raise Insika::ValidationError,
53
+ "content for '#{filename}' (agent '#{agent_id}') must be text, got #{content.class} — " \
54
+ "pass the file's markdown, not the store entry or a wrapper object"
55
+ end
56
+
57
+ # Writes (upsert). create_only: refuses to overwrite. Versions the previous
58
+ # content into history. -> Hash (the stored entry).
59
+ def write(agent_id, filename, content, create_only: false)
60
+ name = filename.to_s
61
+ content = self.class.text!(content, agent_id, name)
62
+ record = @cs.get(SCOPE, agent_id.to_s) || { "files" => {} }
63
+ record["files"] ||= {}
64
+ current = record["files"][name]
65
+ if create_only && current
66
+ raise Insika::ValidationError, "file '#{name}' already exists for agent '#{agent_id}'"
67
+ end
68
+
69
+ record["files"][name] = build_entry(content.to_s, current)
70
+ @cs.put(SCOPE, agent_id.to_s, record)
71
+ record["files"][name]
72
+ end
73
+
74
+ # -> bool (did it exist?). Removes the file; if the agent ends up with no files,
75
+ # keeps the empty record (cheap; deleting the agent handles the cleanup).
76
+ def delete(agent_id, filename)
77
+ name = filename.to_s
78
+ record = @cs.get(SCOPE, agent_id.to_s)
79
+ return false unless record && record.dig("files", name)
80
+
81
+ record["files"].delete(name)
82
+ @cs.put(SCOPE, agent_id.to_s, record)
83
+ true
84
+ end
85
+
86
+ # -> [ { "content" =>, "at" => } ] older versions, most recent first.
87
+ def versions(agent_id, filename)
88
+ entry(agent_id, filename.to_s)&.fetch("history", []) || []
89
+ end
90
+
91
+ # Restores version `index` from history as the current content (a new write:
92
+ # the current one goes to the top of history). -> Hash (entry) | raises if index
93
+ # invalid / file nonexistent.
94
+ def restore(agent_id, filename, index)
95
+ name = filename.to_s
96
+ hist = versions(agent_id, name)
97
+ i = Integer(index)
98
+ unless entry(agent_id, name)
99
+ raise Insika::NotFoundError, "file '#{name}' not found for agent '#{agent_id}'"
100
+ end
101
+ raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
102
+
103
+ write(agent_id, name, hist[i]["content"])
104
+ end
105
+
106
+ private
107
+
108
+ def files(agent_id)
109
+ (@cs.get(SCOPE, agent_id.to_s) || {})["files"] || {}
110
+ end
111
+
112
+ def entry(agent_id, filename)
113
+ files(agent_id)[filename]
114
+ end
115
+
116
+ def build_entry(content, current)
117
+ history = current ? current.fetch("history", []) : []
118
+ if current
119
+ history = [{ "content" => current["content"], "at" => current["updated_at"] }] + history
120
+ history = history.first(HISTORY_MAX)
121
+ end
122
+ { "content" => content, "updated_at" => Time.now.utc.iso8601, "history" => history }
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coercion"
4
+
5
+ module Insika
6
+ # Single point of per-agent policy.
7
+ # ONE allowlist semantics for tools, skills, providers and workflows
8
+ # (nil = all [+ opt-in for optional tools]; [] = none for skills/allow;
9
+ # [names] = the final set; deny always wins) — a single rule, tested once.
10
+ #
11
+ # Deliberate EXCEPTION: `capabilities` does NOT follow the
12
+ # `nil = all` rule. nil/absent = NO capability (explicit opt-in — exposing
13
+ # every registered capability by mistake would couple the agent to plugins it
14
+ # didn't ask for). Do NOT "fix" it to be consistent with tools_allow.
15
+ AgentProfile = Data.define(
16
+ :id, :model, :provider,
17
+ :base_prompt, :prompt_files,
18
+ :tools_allow, :tools_deny,
19
+ :tools_allow_groups, # per-GROUP allowlist:
20
+ # union with tools_allow; deny wins; both
21
+ # nil = all (parity). Expands to the group's
22
+ # tools in the ToolAllowlist policy.
23
+ :skills,
24
+ :skills_eager, # progressive disclosure OFF, wholly or in part:
25
+ # nil/false = level 1 + load_skill (parity); true = every
26
+ # allowed skill; [names] = exactly these. An eager skill's
27
+ # BODY enters the prompt each turn, it leaves the
28
+ # <available_skills> catalog and load_skill refuses it.
29
+ # Removes the activation DECISION (no miss rate) at the
30
+ # cost of the bodies' tokens — measure them against
31
+ # context_budget before turning it on. Same opt-in as
32
+ # `memory`. It lives HERE and not in the SKILL.md
33
+ # frontmatter because skills are shared between agents:
34
+ # a per-skill flag forced one decision onto every
35
+ # allowlist holding the skill. NOT `Allowlist`
36
+ # semantics — nil means NONE here (SkillCatalog#eager_for).
37
+ :context_providers, # provider allowlist
38
+ :workflows_allow, # applied by WorkflowAllowlist
39
+ :policies, # names in the Policy Registry
40
+ :prompt_refs, # names in the Prompt Catalog
41
+ :limits, # timeouts/budgets
42
+ :approvals_required, # tools that require approval (ApprovalRequired)
43
+ :capabilities, # intents the agent can trigger.
44
+ # nil = NONE (opt-in, see above).
45
+ :subagents, # allowlist of child agent ids this agent MAY spawn
46
+ # CAPACITY field — NEVER inherits;
47
+ # opt-in like `capabilities`: nil/absent = NONE (do NOT
48
+ # "fix" to nil = all). Present => the `spawn_subagent`
49
+ # system tool is wired (ChatBuilder), gated by this set.
50
+ :tools_deferred, # searchable-not-wired tools (Tool Search).
51
+ # nil = no deferred (all eager — parity);
52
+ # [names] ⊆ allowed_tools, exposed via tool_search.
53
+ :memory, # cross-session memory.
54
+ # nil/false = OFF (parity: provider []; the `remember`
55
+ # tool not wired); true = ON. Same opt-in as capabilities.
56
+ :prompt_caching, # Anthropic prompt caching (R3): nil/false = OFF
57
+ # (parity); true = ON. Same opt-in as `memory`. When ON
58
+ # AND the resolved provider is Anthropic, ChatBuilder sets
59
+ # ONE cache breakpoint at the end of the system block
60
+ # (caches tools+system by the tools->system->messages
61
+ # prefix order; immune to history eviction). PRE-AUDIT:
62
+ # the system prompt MUST be byte-stable between turns —
63
+ # a context provider injecting volatile content into
64
+ # :system turns every turn into a paid cache WRITE with
65
+ # no read hit. Enable only for stable-system agents.
66
+ :tool_output_compression, # MECHANICAL tool-result dedupe in the replayed
67
+ # history (A3/C3): nil/false = OFF (parity); true = ON.
68
+ # Same opt-in as `memory`. When ON, the history the
69
+ # Session provider seeds replaces byte-identical repeated
70
+ # tool results with a compact back-reference (first
71
+ # occurrence stays full) — no LLM involved. CHANGES WHAT
72
+ # THE MODEL SEES: an older full result is only the first
73
+ # occurrence; a model that wants an older detail re-calls
74
+ # the tool. Cheap half of compaction for bloated histories.
75
+ :params, # LLM generation params: a Hash with
76
+ # temperature/max_tokens/thinking, applied to the chat at
77
+ # stage 5. {} = provider defaults (parity).
78
+ :budget, # spend caps per (tenant, agent) over
79
+ # CALENDAR windows (WS2): { "daily" => int,
80
+ # "monthly" => int, "soft" => bool, "alert_at" => 0.8 }.
81
+ # HARD is the default: absent/"soft": false, a turn
82
+ # arriving at/over the cap fails with Insika::BudgetExceeded
83
+ # and the envelope quotes `budget_exceeded` + retry_after.
84
+ # "soft": true crosses the cap and still runs
85
+ # (one budget_warning event per window + a note in the
86
+ # context).
87
+ # Tokens count the billed spend (input+output+cached+
88
+ # cache_creation). nil/absent = no budget (parity).
89
+ :reliability, # the provider-interaction reliability policy (WS3):
90
+ # { "retries" => 3, "backoff" => "exponential",
91
+ # "fallback" => ["gpt-4o-mini", ...],
92
+ # "circuit_breaker" => { "after" => 10, "within" => 60,
93
+ # "cooldown" => 300 }, "timeout" => 30 }. Data, never
94
+ # DSL: retries + exponential backoff on :retryable /
95
+ # :rate_limited_* failures (never :fatal), mid-turn
96
+ # rotation across the fallback chain (profile's first,
97
+ # then the platform's resolved fallbacks), and a circuit
98
+ # breaker per (tenant, provider/model) that fail-fasts
99
+ # with circuit_open + retry_after once the window count
100
+ # trips. nil/absent = the plain single attempt (parity).
101
+ :alerts, # operator alert delivery (WS6): { "webhook" => url }.
102
+ # When present, the agent's budget_warning /
103
+ # breaker_open / delivery_failed events are POSTed to
104
+ # the URL as JSON (outbox + claim, at-most-once).
105
+ # nil/absent = no webhook (parity).
106
+ :stuck_signal, # the agent may signal it cannot proceed (WS5):
107
+ # nil/false = OFF (parity — the signal_stuck system
108
+ # tool is not wired); true = ON (the model may call
109
+ # signal_stuck, which ends the turn with
110
+ # `outcome: :stuck` + a final message + a :turn_stuck
111
+ # event the consumer acts on). Same opt-in as
112
+ # `memory`. What "stuck" MEANS is the consumer's call
113
+ # (escalation via CRM/operator), never the engine's.
114
+ :model_policy, # governance of WHICH models the agent may use:
115
+ # { "allow" => [refs] }. nil = NO fence (all models —
116
+ # parity). Enforced on the RESOLVED model (ModelResolver).
117
+ :guardrails, # content-safety config: { input:, output:,
118
+ # moderator:, strictness: }. OPT-IN like capabilities —
119
+ # nil/absent = the conservative default (Safety::Config:
120
+ # deterministic on, moderator off). Parsed, never a policy.
121
+ :sandbox, # confined-execution config:
122
+ # { provider: "local"|"docker", root:, timeout:, ...+provider
123
+ # keys }. Declarative provider selection (config-over-code) —
124
+ # consumed by Insika::Sandbox.build. {} = absent (a
125
+ # deployment builds a `local` sandbox by default). It is
126
+ # CONFIG, never a policy — it does not decide security by
127
+ # itself; the FS boundary + approvals do.
128
+ :refinement, # self-improvement config:
129
+ # { mode: "report"|"propose"|"auto_apply", window: {…},
130
+ # files: [allowlist], proposers: [refs], budget: {tokens:},
131
+ # auto_apply_max_edits:, max_findings:, … }. nil/absent =
132
+ # REPORT-ONLY (writes nothing to the agent, so
133
+ # reading your own traces needs no opt-in); `propose`
134
+ # and above must be enabled explicitly. It is CONFIG,
135
+ # never a policy — the write allowlist it carries is
136
+ # enforced by the applier, not by this field.
137
+ :capabilities_declared, # FACTS ABOUT THIS DEPLOYMENT that are not tools
138
+ # %w[promotions human_handoff
139
+ # b2b_pricing]. An eval case declares what it
140
+ # `requires` and is SKIPPED — never failed — where
141
+ # the deployment lacks it, which is what makes one
142
+ # corpus usable across stores. A flat list the
143
+ # OPERATOR writes: inferring "this store has
144
+ # promotions" from data is how a suite starts lying.
145
+ # nil/[] = declares nothing.
146
+ # NOT `capabilities` above — that one is the
147
+ # capability-resolution intents the agent may
148
+ # trigger, a runtime allowlist. This one decides
149
+ # nothing at runtime and is read only by the evals.
150
+ :edge_stream, # WHICH INTERNAL CHANNELS MAY CROSS TO THE CUSTOMER:
151
+ # { "thinking" => bool, "intermediate" => bool }.
152
+ # {} / absent = NEITHER, which is the safe default and
153
+ # the reason the engine holds them back at all: the
154
+ # answer is `:content`, and a turn's other text (the
155
+ # provider's reasoning, the model narrating its tool
156
+ # loop) is for the Studio and the trace. A product that
157
+ # WANTS to show reasoning — a chat UI with a "thinking"
158
+ # panel — opts in per agent, and each channel then gets
159
+ # its OWN frame type at `/v1/responses`, never the
160
+ # answer's. Turning it on for a channel where the
161
+ # consumer concatenates every delta into one message
162
+ # (WhatsApp) puts the deliberation in front of a
163
+ # customer; that is the operator's call to make, not a
164
+ # default to inherit.
165
+ :metadata # free-form agent metadata, stable per agent
166
+ # (from the pack `agent.config.json`). Home of the `store_id`
167
+ # that becomes turn context (ctx.store_id).
168
+ # It is NOT a policy — never decides security. {} = absent.
169
+ )
170
+
171
+ # Reopened class (not a Data.define block): a constant assigned inside
172
+ # the block would leak into the lexical scope (Insika::DEFAULT_LIMITS).
173
+ class AgentProfile
174
+ DEFAULT_LIMITS = {
175
+ turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
176
+ context_budget: 8_000, max_tool_calls: 50,
177
+ # consecutive identical (tool, args) calls that trigger the ONE
178
+ # loop warning; a repeat after it aborts like max_tool_calls. < 2 = off.
179
+ max_tool_repeat: 3,
180
+ approval_timeout: 3_600, # cap on the wait for human approval (~1h)
181
+ # parallel tool calls. ONE number is both the switch and the cap
182
+ # (nil/0/1 = serial, the default; N > 1 = at most N tool calls in flight).
183
+ # It sits next to tool_timeout/max_tool_calls because it is the third bound
184
+ # on tool execution. Read through TurnState#tool_concurrency, which also
185
+ # applies the approval gate.
186
+ tool_concurrency: 1
187
+ }.freeze
188
+
189
+ # `model` is OPTIONAL as of v2: an agent without one resolves the
190
+ # platform `default_model` (Settings) at turn start via the ModelResolver.
191
+ def self.build(id:, model: nil, provider: nil, base_prompt: "", prompt_files: [],
192
+ tools_allow: nil, tools_deny: [], tools_allow_groups: nil, skills: nil,
193
+ skills_eager: nil, context_providers: nil, workflows_allow: nil,
194
+ policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
195
+ capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
196
+ prompt_caching: nil, tool_output_compression: nil,
197
+ params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
198
+ refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
199
+ budget: nil, reliability: nil, alerts: nil, stuck_signal: nil)
200
+ new(
201
+ id: id, model: model, provider: provider, base_prompt: base_prompt,
202
+ prompt_files: Array(prompt_files), tools_allow: tools_allow,
203
+ tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
204
+ skills_eager: skills_eager,
205
+ context_providers: context_providers, workflows_allow: workflows_allow,
206
+ policies: Array(policies), prompt_refs: Array(prompt_refs),
207
+ limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
208
+ capabilities: capabilities,
209
+ # opt-in like capabilities: nil => NONE. Array-normalize a present value so
210
+ # readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
211
+ subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
212
+ tools_deferred: tools_deferred, memory: memory,
213
+ prompt_caching: prompt_caching, tool_output_compression: tool_output_compression,
214
+ # The free-form hashes arrive with symbol keys (internal build) OR string
215
+ # keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
216
+ # here — the single front door every profile passes through — so no reader
217
+ # downstream has to defend against both (store_id, model_policy, Studio forms).
218
+ params: Coercion.deep_stringify(params || {}),
219
+ model_policy: Coercion.deep_stringify(model_policy),
220
+ guardrails: Coercion.deep_stringify(guardrails),
221
+ sandbox: Coercion.deep_stringify(sandbox),
222
+ refinement: Coercion.deep_stringify(refinement),
223
+ # Flat [String] — the evals compare it against a case's `requires`, and a
224
+ # symbol/string mix there would be a silent miss.
225
+ capabilities_declared: Array(capabilities_declared).map(&:to_s),
226
+ edge_stream: Coercion.deep_stringify(edge_stream || {}),
227
+ metadata: Coercion.deep_stringify(metadata || {}),
228
+ budget: Coercion.deep_stringify(budget),
229
+ reliability: Coercion.deep_stringify(reliability),
230
+ alerts: Coercion.deep_stringify(alerts),
231
+ stuck_signal: stuck_signal
232
+ )
233
+ end
234
+
235
+ # opt-in for an optional tool = being in the agent's allow list.
236
+ def tool_opted_in?(name)
237
+ Array(tools_allow).include?(name)
238
+ end
239
+
240
+ # store_id of the turn context (ctx.store_id): lives in `metadata` (stable
241
+ # per store, comes from the pack). `build` string-keys metadata, so a plain
242
+ # string lookup is enough. nil = absent (the data-tool emits an empty header).
243
+ # It is NOT consumer-specific: `store_id` is a field of the turn-context contract
244
+ # generic per project.
245
+ def store_id = (metadata || {})["store_id"]
246
+
247
+ # May this channel (:thinking / :intermediate) cross to the customer? Tolerant
248
+ # of the string values a form or a pack round-trip produces ("1"/"true"), and
249
+ # of anything else being absent: the safe reading is the default one.
250
+ def stream_public?(channel)
251
+ v = (edge_stream || {})[channel.to_s]
252
+ Coercion.truthy?(v)
253
+ end
254
+ end
255
+ 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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Profile allowlist semantics, defined once: nil = all; [] = none;
5
+ # [names] = subset. It used to be copied in the Builder, the SkillCatalog and the
6
+ # policies — the rule must live in a single place.
7
+ module Allowlist
8
+ module_function
9
+
10
+ # Filters a collection by the allowlist, comparing `key.call(candidate)` (or the
11
+ # candidate itself) against the allowed names.
12
+ def filter(candidates, allow, &key)
13
+ return candidates if allow.nil?
14
+ return [] if allow.empty?
15
+
16
+ names = Array(allow).map(&:to_s)
17
+ candidates.select { |c| names.include?((key ? key.call(c) : c).to_s) }
18
+ end
19
+
20
+ # Per-item boolean variant (to compose with other filters in a select).
21
+ def allows?(allow, value)
22
+ return true if allow.nil?
23
+ return false if allow.empty?
24
+
25
+ Array(allow).map(&:to_s).include?(value.to_s)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # The ACCEPTED state of an agent's golden set (promoted to a store
7
+ # by). One record per agent in the ConfigStore (scope "baselines"):
8
+ #
9
+ # { "at" => iso8601, "cases" => { "<case id>" => { "pass" => bool, "score" => n } } }
10
+ #
11
+ # Exactly the shape `Evals::Baseline.snapshot` already produces, so the file and
12
+ # the record are the same document in two places — no converter to keep honest,
13
+ # the same discipline `GoldenStore` applies to the corpus.
14
+ #
15
+ # **Why it had to leave the file.** `evals/baseline.json` works fine for the CLI,
16
+ # which runs from a checkout. The refinement gate does not: it runs inside a
17
+ # Railway deployment, scores a throwaway clone against the accepted state, and
18
+ # there is no checkout there to read. A gate that fell back to "no baseline found,
19
+ # nothing regressed" would be the most dangerous default in the system — so the
20
+ # baseline became a per-agent record, and the gate refuses when it is missing.
21
+ #
22
+ # The file stays the export format and the seed for a fresh deploy
23
+ # (`insika evals:baseline import|export`), for the same reason the goldens' YAML
24
+ # does. It is also still what `evals/run.rb --gate` reads for the pre-merge check,
25
+ # which is a checkout-side job and should stay one.
26
+ #
27
+ # Per AGENT and not one blob, unlike the file: a deployment serves many agents, a
28
+ # refinement run is about exactly one, and re-baselining the store after fixing
29
+ # agent A must not silently accept agent B's current state.
30
+ class BaselineStore
31
+ SCOPE = "baselines"
32
+
33
+ def initialize(config_store:)
34
+ @cs = config_store
35
+ end
36
+
37
+ # -> { "at" =>, "cases" => {…} } | nil. nil means NOT RECORDED, which every
38
+ # caller must treat as "cannot gate", never as "nothing regressed".
39
+ def get(agent_id)
40
+ @cs.get(SCOPE, agent_id.to_s)
41
+ end
42
+
43
+ # -> the stored record. `snapshot` is `Evals::Baseline.snapshot` output (or the
44
+ # equivalent hash); `at` is stamped here when the snapshot carries none.
45
+ def put(agent_id, snapshot, at: nil)
46
+ raw = Coercion.deep_stringify(snapshot || {})
47
+ cases = raw["cases"]
48
+ raise Insika::ValidationError, "baseline needs a 'cases' mapping" unless cases.is_a?(Hash)
49
+
50
+ record = { "at" => Coercion.presence(raw["at"]) || at || timestamp, "cases" => cases }
51
+ @cs.put(SCOPE, agent_id.to_s, record)
52
+ record
53
+ end
54
+
55
+ # -> bool (did it exist?). Removing a baseline disables the gate for that agent,
56
+ # which is the honest consequence and not a side effect worth hiding.
57
+ def delete(agent_id) = !!@cs.delete(SCOPE, agent_id.to_s)
58
+
59
+ # -> [String] agents with a recorded baseline.
60
+ def agents = @cs.keys(SCOPE)
61
+
62
+ # How many cases the accepted state covers. `0` and `nil` are different answers:
63
+ # nil = never recorded, 0 = recorded and empty (every case was skipped), and only
64
+ # the first is a configuration mistake.
65
+ def size(agent_id)
66
+ record = get(agent_id)
67
+ record && (record["cases"] || {}).size
68
+ end
69
+
70
+ private
71
+
72
+ def timestamp = Time.now.utc.iso8601
73
+ end
74
+ end