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,188 @@
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 (Phase 7/D4/F5, Stage C):
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
+ :context_providers, # provider allowlist
25
+ :workflows_allow, # applied by WorkflowAllowlist
26
+ :policies, # names in the Policy Registry
27
+ :prompt_refs, # names in the Prompt Catalog
28
+ :limits, # timeouts/budgets
29
+ :approvals_required, # tools that require approval (ApprovalRequired)
30
+ :capabilities, # intents the agent can trigger.
31
+ # nil = NONE (opt-in, see above).
32
+ :subagents, # allowlist of child agent ids this agent MAY spawn
33
+ # (RFC-0010, item 21). CAPACITY field — NEVER inherits;
34
+ # opt-in like `capabilities`: nil/absent = NONE (do NOT
35
+ # "fix" to nil = all). Present => the `spawn_subagent`
36
+ # system tool is wired (ChatBuilder), gated by this set.
37
+ :tools_deferred, # searchable-not-wired tools (Tool Search).
38
+ # nil = no deferred (all eager — parity);
39
+ # [names] ⊆ allowed_tools, exposed via tool_search.
40
+ :memory, # cross-session memory.
41
+ # nil/false = OFF (parity: provider []; the `remember`
42
+ # tool not wired); true = ON. Same opt-in as capabilities.
43
+ :prompt_caching, # Anthropic prompt caching (§11 R3): nil/false = OFF
44
+ # (parity); true = ON. Same opt-in as `memory`. When ON
45
+ # AND the resolved provider is Anthropic, ChatBuilder sets
46
+ # ONE cache breakpoint at the end of the system block
47
+ # (caches tools+system by the tools->system->messages
48
+ # prefix order; immune to history eviction). PRE-AUDIT:
49
+ # the system prompt MUST be byte-stable between turns —
50
+ # a context provider injecting volatile content into
51
+ # :system turns every turn into a paid cache WRITE with
52
+ # no read hit. Enable only for stable-system agents.
53
+ :params, # LLM generation params (v2, §10): a Hash with
54
+ # temperature/max_tokens/thinking, applied to the chat at
55
+ # stage 5. {} = provider defaults (parity).
56
+ :model_policy, # governance of WHICH models the agent may use (v2, §10):
57
+ # { "allow" => [refs] }. nil = NO fence (all models —
58
+ # parity). Enforced on the RESOLVED model (ModelResolver).
59
+ :guardrails, # content-safety config (RFC-0009 §3.3): { input:, output:,
60
+ # moderator:, strictness: }. OPT-IN like capabilities —
61
+ # nil/absent = the conservative default (Safety::Config:
62
+ # deterministic on, moderator off). Parsed, never a policy.
63
+ :sandbox, # confined-execution config (item 35, §4.6):
64
+ # { provider: "local"|"docker", root:, timeout:, ...+provider
65
+ # keys }. Declarative provider selection (config-over-code) —
66
+ # consumed by Insika::Sandbox.build. {} = absent (a
67
+ # deployment builds a `local` sandbox by default). It is
68
+ # CONFIG, never a policy — it does not decide security by
69
+ # itself; the FS boundary + approvals do.
70
+ :refinement, # self-improvement config (RFC-0013 §3.8):
71
+ # { mode: "report"|"propose"|"auto_apply", window: {…},
72
+ # files: [allowlist], proposers: [refs], budget: {tokens:},
73
+ # auto_apply_max_edits:, max_findings:, … }. nil/absent =
74
+ # REPORT-ONLY (phase A writes nothing to the agent, so
75
+ # reading your own traces needs no opt-in); `propose`
76
+ # and above must be enabled explicitly. It is CONFIG,
77
+ # never a policy — the write allowlist it carries is
78
+ # enforced by the applier, not by this field.
79
+ :capabilities_declared, # FACTS ABOUT THIS DEPLOYMENT that are not tools
80
+ # (RFC-0014 §3.5): %w[promotions human_handoff
81
+ # b2b_pricing]. An eval case declares what it
82
+ # `requires` and is SKIPPED — never failed — where
83
+ # the deployment lacks it, which is what makes one
84
+ # corpus usable across stores. A flat list the
85
+ # OPERATOR writes: inferring "this store has
86
+ # promotions" from data is how a suite starts lying.
87
+ # nil/[] = declares nothing.
88
+ # NOT `capabilities` above — that one is the
89
+ # capability-resolution intents the agent may
90
+ # trigger, a runtime allowlist. This one decides
91
+ # nothing at runtime and is read only by the evals.
92
+ :edge_stream, # WHICH INTERNAL CHANNELS MAY CROSS TO THE CUSTOMER:
93
+ # { "thinking" => bool, "intermediate" => bool }.
94
+ # {} / absent = NEITHER, which is the safe default and
95
+ # the reason the engine holds them back at all: the
96
+ # answer is `:content`, and a turn's other text (the
97
+ # provider's reasoning, the model narrating its tool
98
+ # loop) is for the Studio and the trace. A product that
99
+ # WANTS to show reasoning — a chat UI with a "thinking"
100
+ # panel — opts in per agent, and each channel then gets
101
+ # its OWN frame type at `/v1/responses`, never the
102
+ # answer's. Turning it on for a channel where the
103
+ # consumer concatenates every delta into one message
104
+ # (WhatsApp) puts the deliberation in front of a
105
+ # customer; that is the operator's call to make, not a
106
+ # 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.
111
+ )
112
+
113
+ # Reopened class (not a Data.define block): a constant assigned inside
114
+ # the block would leak into the lexical scope (Insika::DEFAULT_LIMITS).
115
+ class AgentProfile
116
+ DEFAULT_LIMITS = {
117
+ turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
118
+ context_budget: 8_000, max_tool_calls: 50,
119
+ 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
121
+ # (nil/0/1 = serial, the default; N > 1 = at most N tool calls in flight).
122
+ # It sits next to tool_timeout/max_tool_calls because it is the third bound
123
+ # on tool execution. Read through TurnState#tool_concurrency, which also
124
+ # applies the approval gate (D3).
125
+ tool_concurrency: 1
126
+ }.freeze
127
+
128
+ # `model` is OPTIONAL as of v2 (§10): an agent without one resolves the
129
+ # platform `default_model` (Settings) at turn start via the ModelResolver.
130
+ def self.build(id:, model: nil, provider: nil, base_prompt: "", prompt_files: [],
131
+ tools_allow: nil, tools_deny: [], tools_allow_groups: nil, skills: nil,
132
+ context_providers: nil, workflows_allow: nil,
133
+ policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
134
+ 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: {})
138
+ new(
139
+ id: id, model: model, provider: provider, base_prompt: base_prompt,
140
+ prompt_files: Array(prompt_files), tools_allow: tools_allow,
141
+ tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
142
+ context_providers: context_providers, workflows_allow: workflows_allow,
143
+ policies: Array(policies), prompt_refs: Array(prompt_refs),
144
+ limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
145
+ capabilities: capabilities,
146
+ # opt-in like capabilities: nil => NONE. Array-normalize a present value so
147
+ # readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
148
+ subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
149
+ tools_deferred: tools_deferred, memory: memory,
150
+ prompt_caching: prompt_caching,
151
+ # The free-form hashes arrive with symbol keys (internal build) OR string
152
+ # keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
153
+ # here — the single front door every profile passes through — so no reader
154
+ # downstream has to defend against both (store_id, model_policy, Studio forms).
155
+ params: Coercion.deep_stringify(params || {}),
156
+ model_policy: Coercion.deep_stringify(model_policy),
157
+ guardrails: Coercion.deep_stringify(guardrails),
158
+ sandbox: Coercion.deep_stringify(sandbox),
159
+ refinement: Coercion.deep_stringify(refinement),
160
+ # Flat [String] — the evals compare it against a case's `requires`, and a
161
+ # symbol/string mix there would be a silent miss.
162
+ capabilities_declared: Array(capabilities_declared).map(&:to_s),
163
+ edge_stream: Coercion.deep_stringify(edge_stream || {}),
164
+ metadata: Coercion.deep_stringify(metadata || {})
165
+ )
166
+ end
167
+
168
+ # opt-in for an optional tool = being in the agent's allow list.
169
+ def tool_opted_in?(name)
170
+ Array(tools_allow).include?(name)
171
+ end
172
+
173
+ # store_id of the turn context (ctx.store_id): lives in `metadata` (stable
174
+ # per store, comes from the pack). `build` string-keys metadata, so a plain
175
+ # 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.
178
+ def store_id = (metadata || {})["store_id"]
179
+
180
+ # May this channel (:thinking / :intermediate) cross to the customer? Tolerant
181
+ # of the string values a form or a pack round-trip produces ("1"/"true"), and
182
+ # of anything else being absent: the safe reading is the default one.
183
+ def stream_public?(channel)
184
+ v = (edge_stream || {})[channel.to_s]
185
+ [true, "true", "1", "yes", "on"].include?(v)
186
+ end
187
+ end
188
+ 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 (RFC-0008 §3.4, promoted to a store
7
+ # by RFC-0013 §3.7.3). 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
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Insika
6
+ module Capability
7
+ # Thin decorator: swaps only the `name` exposed to the model for the
8
+ # STABLE capability name (e.g. "browse"), regardless of which concrete impl
9
+ # (`impl_name`, e.g. "puppeteer_browser") resolution chose.
10
+ # `execute`/`parameters`/`description`/`call` keep delegating to the impl via
11
+ # SimpleDelegator — nothing reimplemented, same spirit as `ToolEnvelope`.
12
+ #
13
+ # Wrapping order in run_pipeline: impl -> ResolvedTool ->
14
+ # ToolEnvelope. The Envelope, from the outside, sees the already-renamed call (the
15
+ # model calls `browse`); for side_effect?/approval it needs the REAL `impl_name`
16
+ # (it's the tool_registry that knows whether "puppeteer_browser" is a side-effect, not
17
+ # "browse") — that's why `impl_name` is exposed here. This is consumed by the
18
+ # Executor (ToolEnvelope).
19
+ class ResolvedTool < SimpleDelegator
20
+ def initialize(impl, capability_name:, impl_name:)
21
+ super(impl)
22
+ @capability_name = capability_name.to_s
23
+ @impl_name = impl_name.to_s
24
+ end
25
+
26
+ # STABLE name exposed to the model — shadows the impl's `name`.
27
+ def name = @capability_name
28
+
29
+ # Concrete name behind resolution — for side_effect?/approval in the
30
+ # ToolEnvelope, NEVER exposed to the model.
31
+ def impl_name = @impl_name
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Intent→implementation resolution. Pure INDIRECTION: does NOT inherit
5
+ # from `Registry` (which holds executables) — it holds `Provider`s (resolution
6
+ # metadata) and returns the `impl_name` that ANOTHER registry instantiates.
7
+ # Immutable post-boot by construction (only boot/loader registers), like `Registry`.
8
+ class CapabilityRegistry
9
+ Provider = Data.define(:capability, :impl_name, :kind, :plugin, :priority, :available)
10
+ # kind: :tool | :workflow
11
+ # priority: Integer | nil (nil = lowest; inherits plugin precedence)
12
+ # available: callable -> bool (default -> { true }; never nil in a registered Provider)
13
+
14
+ def initialize
15
+ # capability(Symbol) -> [Provider], na ordem de registro (proxy de announce)
16
+ @providers = Hash.new { |h, k| h[k] = [] }
17
+ end
18
+
19
+ # Unlike `Registry#register`, there is NO "first wins": registering the
20
+ # same capability more than once is the normal case (providers competing) —
21
+ # dedup/tie-breaking happens in `resolve`, not here.
22
+ def register(capability, impl_name:, kind:, plugin: nil, priority: nil, available: nil)
23
+ unless %i[tool workflow].include?(kind)
24
+ raise ArgumentError, "invalid kind: #{kind.inspect} (use :tool or :workflow)"
25
+ end
26
+
27
+ if kind == :workflow
28
+ warn "[capability_registry] '#{capability}' registered with kind: :workflow — " \
29
+ "exposure to the agent deferred (L5)"
30
+ end
31
+
32
+ @providers[capability.to_sym] << Provider.new(
33
+ capability: capability.to_sym, impl_name: impl_name.to_s, kind: kind,
34
+ plugin: plugin&.to_s, priority: priority, available: available || -> { true }
35
+ )
36
+ self
37
+ end
38
+
39
+ def providers(capability) = @providers[capability.to_sym].dup
40
+
41
+ def capabilities = @providers.keys
42
+
43
+ # Loader rollback, symmetric to `Registry#deregister_plugin`. Removes
44
+ # only the plugin's Providers; capabilities with no remaining provider drop
45
+ # from `capabilities` (clears the key so the Hash.new-with-block won't recreate it empty).
46
+ def deregister_plugin(plugin_id)
47
+ @providers.each_value { |list| list.delete_if { |p| p.plugin == plugin_id.to_s } }
48
+ @providers.delete_if { |_cap, list| list.empty? }
49
+ nil
50
+ end
51
+
52
+ # -> chosen Provider | raise CapabilityUnavailable | raise CapabilityAmbiguous.
53
+ # PURE and deterministic: same input → same choice or same error. No
54
+ # IO beyond the Provider's own `available.call`. Emits `:capability_resolved`
55
+ # when `event_stream:` is present (audit).
56
+ def resolve(capability, profile:, context: {}, event_stream: nil)
57
+ candidates = providers(capability)
58
+ candidates = candidates.select { |p| p.available.call }
59
+ candidates = apply_deny(candidates, profile)
60
+
61
+ raise CapabilityUnavailable.new(capability: capability) if candidates.empty?
62
+
63
+ chosen = pick_top(candidates, capability)
64
+ event_stream&.emit(Insika::Event.new(
65
+ type: :capability_resolved,
66
+ data: {
67
+ capability: capability.to_sym,
68
+ chosen: chosen.impl_name,
69
+ candidates: candidates.map do |p|
70
+ { impl_name: p.impl_name, plugin: p.plugin, priority: p.priority }
71
+ end
72
+ }
73
+ ))
74
+ chosen
75
+ end
76
+
77
+ private
78
+
79
+ # Resolution applies ONLY `tools_deny` over `impl_name` (deny ALWAYS wins) — it does
80
+ # NOT apply `tools_allow`: the grant to use the capability is
81
+ # listing it in `profile.capabilities`, checked by the Executor BEFORE
82
+ # calling `resolve`. Reusing `tools_allow` would filter out a provider for
83
+ # an agent that lists only the capability (not the raw impl). Per-agent provider
84
+ # pinning (`capability_providers`) is future work.
85
+ def apply_deny(candidates, profile)
86
+ deny = Array(profile.tools_deny).map(&:to_s)
87
+ candidates.reject { |p| deny.include?(p.impl_name) }
88
+ end
89
+
90
+ # `priority` desc primary, `nil` as the LOWEST possible (below
91
+ # any Integer, including negative — do not normalize to 0, which would collide
92
+ # with an explicit `priority: 0`). Tie-break by plugin precedence (registration
93
+ # order, announce proxy): different plugins always
94
+ # break the tie; same plugin (nil included) tied = CapabilityAmbiguous.
95
+ def pick_top(candidates, capability)
96
+ indexed = providers(capability).each_with_index.to_h { |p, i| [p, i] }
97
+
98
+ rank = ->(p) { p.priority.nil? ? [0, 0] : [1, p.priority] }
99
+ top_rank = candidates.map(&rank).max
100
+ top = candidates.select { |p| rank.call(p) == top_rank }
101
+
102
+ return top.first if top.size == 1
103
+
104
+ groups = top.group_by(&:plugin).values
105
+ if groups.any? { |g| g.size > 1 }
106
+ raise CapabilityAmbiguous.new(capability: capability, candidates: top)
107
+ end
108
+
109
+ groups.min_by { |g| indexed[g.first] }.first
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # Hands a finished turn's answer to a Shape B channel (RFC-0011 §6.5). The turn
7
+ # ended; the recipient is not on any connection; the reply has to travel out of
8
+ # band and survive a crash on the way. Three moves, in this order, and the order
9
+ # is the whole design:
10
+ #
11
+ # 1. RECORD at the turn's terminal (durable, `pending`).
12
+ # 2. CLAIM before the HTTP call (`pending -> delivering`, atomic). A crash
13
+ # between the claim and the POST loses that delivery; it does not duplicate
14
+ # it. At-most-once, stated rather than papered over — the same honest scope
15
+ # the async-delegation path already has.
16
+ # 3. RETRY, bounded and explicit. Unlike a delegation, the recipient is a third
17
+ # party with outages, so "keep trying" is a real requirement and "keep trying
18
+ # forever" is a real outage of ours.
19
+ #
20
+ # It is NOT a job queue: no scheduler, no priorities, no fan-out. The moment it
21
+ # grows one, the thing to do is take a real queue, not to finish building this.
22
+ class ChannelDelivery
23
+ MAX_ATTEMPTS = 3
24
+ # Waits BETWEEN attempts, so attempt 1 is immediate. Short on purpose: a
25
+ # customer waiting on WhatsApp is the deadline, not the consumer's SLA.
26
+ BACKOFF_SECONDS = [1, 5].freeze
27
+
28
+ def initialize(channels:, outbox:, session_store:, event_stream: nil,
29
+ max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil)
30
+ @channels = channels
31
+ @outbox = outbox
32
+ @session_store = session_store
33
+ @event_stream = event_stream
34
+ @max_attempts = max_attempts
35
+ @backoff = Array(backoff)
36
+ @sleeper = sleeper || method(:default_sleep)
37
+ end
38
+
39
+ # The turn committed an answer. -> the Delivery to dispatch, or nil when there
40
+ # is nothing to deliver, which is the common case and must stay cheap:
41
+ # · the turn did not come in through a channel,
42
+ # · the channel is Shape A (answers on its own stream — no `deliver`),
43
+ # · the answer is empty (a turn that died mid-message published nothing, and
44
+ # half a sentence was never an answer — P19),
45
+ # · or we do not know who to send it to.
46
+ def record(task:, channel_id:, content:)
47
+ return nil if content.to_s.strip.empty?
48
+
49
+ channel = @channels&.find(channel_id)
50
+ return nil unless channel.respond_to?(:deliver)
51
+
52
+ to = recipient(channel, task.session_id)
53
+ return nil if to.nil? || to.empty?
54
+
55
+ @outbox.create(
56
+ channel: channel_id, to: to, task_id: task.id, session_id: task.session_id,
57
+ payload: { "session_id" => task.session_id.to_s, "task_id" => task.id.to_s,
58
+ "content" => content.to_s }
59
+ )
60
+ end
61
+
62
+ # Claim + POST + bounded retry. Safe to call twice: the second caller loses the
63
+ # claim and returns without touching the recipient.
64
+ def deliver(id)
65
+ return false unless @outbox.claim(id)
66
+
67
+ delivery = @outbox.find(id)
68
+ channel = @channels&.find(delivery&.channel)
69
+ # The channel vanished between the record and the dispatch (a plugin was
70
+ # disabled, the deployment was reconfigured). Nothing can send this; leave it
71
+ # terminal so the boot sweep does not spin on it forever.
72
+ if channel.nil? || !channel.respond_to?(:deliver)
73
+ return finish(@outbox.mark_failed(id, error: "channel '#{delivery&.channel}' is not registered"))
74
+ end
75
+
76
+ attempt(delivery, channel)
77
+ end
78
+
79
+ # Boot: re-drive what a previous process recorded and never claimed. Records
80
+ # left `delivering` are deliberately NOT swept — that process may have POSTed
81
+ # before it died, and replaying is the duplicate the claim exists to prevent.
82
+ # -> { dispatched: [ids] }
83
+ def sweep
84
+ dispatched = @outbox.pending.map do |delivery|
85
+ deliver(delivery.id)
86
+ delivery.id
87
+ end
88
+ { dispatched: dispatched }
89
+ end
90
+
91
+ private
92
+
93
+ def attempt(delivery, channel)
94
+ last_error = nil
95
+
96
+ @max_attempts.times do |i|
97
+ @sleeper.call(@backoff[i - 1]) if i.positive? && @backoff[i - 1]
98
+
99
+ begin
100
+ status = channel.deliver(delivery.payload, to: delivery.to, delivery_id: delivery.id)
101
+ return finish(@outbox.mark_delivered(delivery.id)) if (200..299).cover?(status.to_i)
102
+
103
+ last_error = "HTTP #{status}"
104
+ rescue Insika::DeliveryError => e
105
+ last_error = e.message
106
+ end
107
+ @outbox.record_attempt(delivery.id, error: last_error)
108
+ end
109
+
110
+ finish(@outbox.mark_failed(delivery.id, error: last_error))
111
+ end
112
+
113
+ # The consumer's own key for this conversation. Written into the session's vars
114
+ # when the channel minted it (§4.3); the channel's own id parser is the fallback
115
+ # for a session created before those vars existed.
116
+ def recipient(channel, session_id)
117
+ session = session_id && @session_store.find(session_id)
118
+ vars = session&.vars || {}
119
+ from_vars = vars["external_id"] || vars[:external_id]
120
+ return from_vars.to_s if Insika::Coercion.presence(from_vars)
121
+
122
+ channel.respond_to?(:external_id_from) ? channel.external_id_from(session_id) : nil
123
+ end
124
+
125
+ def finish(delivery)
126
+ emit(delivery)
127
+ delivery.status == :delivered
128
+ end
129
+
130
+ def emit(delivery)
131
+ return unless @event_stream
132
+
133
+ @event_stream.emit(Insika::Event.new(
134
+ type: :channel_delivered,
135
+ data: { channel: delivery.channel, outbox_id: delivery.id,
136
+ status: delivery.status.to_s, attempts: delivery.attempts,
137
+ error: delivery.last_error },
138
+ meta: { task_id: delivery.task_id, session_id: delivery.session_id,
139
+ at: Time.now.utc.iso8601 }
140
+ ))
141
+ end
142
+
143
+ # Async when there is a reactor (production: the retry must not block the
144
+ # worker), plain sleep otherwise (boot sweep before the reactor, specs).
145
+ def default_sleep(seconds)
146
+ task = defined?(Async::Task) ? Async::Task.current? : nil
147
+ task ? task.sleep(seconds) : sleep(seconds)
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # The channels this deployment speaks, by id (RFC-0011 §4.2). A `Registry` like
5
+ # tools and workflows — same plugin bookkeeping, so `deregister_plugin` rolls a
6
+ # half-registered plugin back exactly as it does for a tool.
7
+ #
8
+ # The id is the URL segment (`/channels/relay/events`), which is why lookup here
9
+ # is a `find` and not a `resolve`: an unknown segment is a 404, not an exception
10
+ # the transport has to rescue.
11
+ class ChannelRegistry < Registry
12
+ # -> the channel instance | nil (unknown id). A blank id never matches.
13
+ def find(id)
14
+ name = id.to_s
15
+ return nil if name.empty?
16
+
17
+ resolve(name)
18
+ rescue Insika::NotFoundError
19
+ nil
20
+ end
21
+
22
+ # Does this channel deliver out of band (Shape B)? A Shape A channel answers on
23
+ # the request's own stream and has no `deliver`, so nothing is ever written to
24
+ # the outbox for it. Duck-typed, like every other seam in the engine.
25
+ def deliverable?(id)
26
+ channel = find(id)
27
+ !channel.nil? && channel.respond_to?(:deliver)
28
+ end
29
+ end
30
+ end