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,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Contract suite for Insika::Store (doc 01 §7), exported for third-party
4
+ # backends (RFC-0018 A4) — a gem like `insika-pg` writes its spec against THIS
5
+ # file, not against a read of stores/sqlite.rb:
6
+ #
7
+ # require "insika/testing/store_contract"
8
+ #
9
+ # RSpec.describe Insika::Stores::PG do
10
+ # subject(:store) { described_class.new(url:) }
11
+ # it_behaves_like "an Insika store"
12
+ # end
13
+ #
14
+ # Two groups, on purpose (RFC-0018 §4 item 4):
15
+ #
16
+ # - "an Insika store" — universal; every backend passes EXACTLY it (L2: the
17
+ # suite is honest — a test that passes on Memory passes on SQLite). The
18
+ # including group defines `store` (an empty, ready backend).
19
+ # - "an Insika store safe for N workers" — OPT-IN; the multi-worker half. The
20
+ # including group ALSO defines `store_factory`, a callable returning ANOTHER
21
+ # connection to the SAME underlying backend. A backend that cannot serialize
22
+ # transactions across connections must not include it — Memory is the
23
+ # canonical example, and a single-connection backend has nothing to prove.
24
+ # Anything that wants to sit under `WEB_CONCURRENCY > 1` has to.
25
+ #
26
+ # Do not include backend-specific cases here (file durability, WAL, boot
27
+ # races) — those belong to the backend's own spec.
28
+ RSpec.shared_examples "an Insika store" do
29
+ describe "#get / #set (round-trip)" do
30
+ it "C1 preserves Hash with string keys" do # C1
31
+ store.set("s", "k", { "a" => 1, "b" => [1, 2] })
32
+ expect(store.get("s", "k")).to eq({ "a" => 1, "b" => [1, 2] })
33
+ end
34
+
35
+ it "C2 preserves Array" do # C2
36
+ store.set("s", "k", [1, "x", true, nil])
37
+ expect(store.get("s", "k")).to eq([1, "x", true, nil])
38
+ end
39
+
40
+ it "C3 preserves String" do # C3
41
+ store.set("s", "k", "texto")
42
+ expect(store.get("s", "k")).to eq("texto")
43
+ end
44
+
45
+ it "C4 preserves Integer" do # C4
46
+ store.set("s", "k", 42)
47
+ value = store.get("s", "k")
48
+ expect(value).to eq(42)
49
+ expect(value).to be_a(Integer)
50
+ end
51
+
52
+ it "C5 preserves Float" do # C5
53
+ store.set("s", "k", 3.14)
54
+ value = store.get("s", "k")
55
+ expect(value).to eq(3.14)
56
+ expect(value).to be_a(Float)
57
+ end
58
+
59
+ it "C6 preserves booleans" do # C6
60
+ store.set("s", "t", true)
61
+ store.set("s", "f", false)
62
+ expect(store.get("s", "t")).to be(true)
63
+ expect(store.get("s", "f")).to be(false)
64
+ end
65
+
66
+ it "C7 preserves nil written without exception" do # C7
67
+ store.set("s", "k", nil)
68
+ expect(store.get("s", "k")).to be_nil
69
+ end
70
+
71
+ it "C8 converts Symbols (keys and values) to Strings" do # C8
72
+ store.set("s", "k", { chave: :valor })
73
+ expect(store.get("s", "k")).to eq({ "chave" => "valor" })
74
+ end
75
+
76
+ it "C9 returns nil for an absent key, never an exception" do # C9
77
+ expect(store.get("s", "nao-existe")).to be_nil
78
+ end
79
+
80
+ it "C10 overwrites silently (last-write-wins)" do # C10
81
+ store.set("s", "k", "primeiro")
82
+ store.set("s", "k", "segundo")
83
+ expect(store.get("s", "k")).to eq("segundo")
84
+ end
85
+
86
+ it "C11 set returns the same object passed in (not the round-trip)" do # C11
87
+ obj = { "a" => 1 }
88
+ expect(store.set("s", "k", obj)).to equal(obj)
89
+ end
90
+ end
91
+
92
+ describe "#delete" do
93
+ it "C12 removes existing and returns true" do # C12
94
+ store.set("s", "k", 1)
95
+ expect(store.delete("s", "k")).to be(true)
96
+ expect(store.get("s", "k")).to be_nil
97
+ end
98
+
99
+ it "C13 returns false for a nonexistent key" do # C13
100
+ expect(store.delete("s", "k")).to be(false)
101
+ end
102
+ end
103
+
104
+ describe "#list" do
105
+ it "C14 returns scope keys sorted lexicographically" do # C14
106
+ store.set("s", "b", 1)
107
+ store.set("s", "a", 1)
108
+ store.set("s", "c", 1)
109
+ expect(store.list("s")).to eq(%w[a b c])
110
+ end
111
+
112
+ it "C15 filters by prefix with start_with? (not include?)" do # C15
113
+ store.set("s", "task:1", 1)
114
+ store.set("s", "task:2", 1)
115
+ store.set("s", "checkpoint:1", 1)
116
+ store.set("s", "my-task:1", 1) # trap: contains "task:" but does not start with it
117
+ expect(store.list("s", "task:")).to eq(%w[task:1 task:2])
118
+ end
119
+
120
+ it "C16 returns [] for an empty scope" do # C16
121
+ expect(store.list("s")).to eq([])
122
+ end
123
+
124
+ it "sorts lexicographically, not numerically" do # edge case 2
125
+ store.set("s", "task:10", 1)
126
+ store.set("s", "task:2", 1)
127
+ expect(store.list("s", "task:")).to eq(%w[task:10 task:2])
128
+ end
129
+ end
130
+
131
+ describe "scope isolation" do
132
+ it "C17 keeps scopes independent in get/list/delete" do # C17
133
+ store.set("s1", "k", 1)
134
+ store.set("s2", "k", 2)
135
+
136
+ expect(store.get("s1", "k")).to eq(1)
137
+ expect(store.get("s2", "k")).to eq(2)
138
+ expect(store.list("s1")).to eq(%w[k])
139
+
140
+ store.delete("s1", "k")
141
+ expect(store.get("s2", "k")).to eq(2)
142
+ end
143
+ end
144
+
145
+ describe "#transaction" do
146
+ it "C18 returns the block's value" do # C18
147
+ expect(store.transaction { 42 }).to eq(42)
148
+ end
149
+
150
+ it "C19 commits the block's writes" do # C19
151
+ store.transaction { store.set("s", "k", "commitado") }
152
+ expect(store.get("s", "k")).to eq("commitado")
153
+ end
154
+
155
+ it "C20 does a real rollback of set AND delete when the block raises" do # C20
156
+ store.set("s", "manter", "antigo")
157
+ store.set("s", "apagar", "existe")
158
+
159
+ expect do
160
+ store.transaction do
161
+ store.set("s", "manter", "novo")
162
+ store.delete("s", "apagar")
163
+ raise "boom"
164
+ end
165
+ end.to raise_error("boom")
166
+
167
+ # all the block's effects undone
168
+ expect(store.get("s", "manter")).to eq("antigo")
169
+ expect(store.get("s", "apagar")).to eq("existe")
170
+ end
171
+
172
+ it "C21 reuses the outer transaction when nested" do # C21
173
+ store.set("s", "k", "antigo")
174
+
175
+ expect do
176
+ store.transaction do
177
+ store.transaction { store.set("s", "k", "novo") }
178
+ raise "boom"
179
+ end
180
+ end.to raise_error("boom")
181
+
182
+ # outer rollback undoes the inner set (no nesting error)
183
+ expect(store.get("s", "k")).to eq("antigo")
184
+ end
185
+ end
186
+
187
+ describe "serialization errors" do
188
+ it "C22 raises StoreError and does not write a non-serializable value" do # C22
189
+ expect do
190
+ store.set("s", "k", Object.new)
191
+ end.to raise_error(Insika::StoreError)
192
+ expect(store.get("s", "k")).to be_nil
193
+ end
194
+ end
195
+ end
196
+
197
+ # Multi-worker safety (RFC-0018 A3). Every RFC-0016 A1 claim (outbox, delegation
198
+ # sweep, recovery) is a read-check-write inside `transaction` — on SQLite that is
199
+ # atomic because the backend opens BEGIN IMMEDIATE; a backend whose transaction
200
+ # only yields passes all 22 cases above and still double-claims under two
201
+ # workers, silently. These cases close that hole, across REAL concurrent
202
+ # connections (threads, each on its own handle from `store_factory`).
203
+ #
204
+ # The `sleep` inside each transaction is deliberate: it holds the read state
205
+ # open long enough that a backend without isolation ALWAYS lets a second
206
+ # connection through, while a correct backend serializes the writers. Without
207
+ # it a wrong backend could pass by scheduling luck — the failure E2 relies on.
208
+ RSpec.shared_examples "an Insika store safe for N workers" do
209
+ # Starts N threads, each holding its OWN connection to the same backend,
210
+ # releases them together, and returns each block's value. Connections are
211
+ # closed on the way out when the backend has a #close.
212
+ def with_concurrent_connections(n, &blk)
213
+ ready = Queue.new
214
+ go = Queue.new
215
+ threads = n.times.map do
216
+ Thread.new do
217
+ conn = store_factory.call
218
+ ready << true
219
+ go.pop # hold every connection at the line, then release them together
220
+ blk.call(conn)
221
+ ensure
222
+ conn.close if conn.respond_to?(:close)
223
+ end
224
+ end
225
+ n.times { ready.pop }
226
+ go.close # a closed queue pops nil immediately: the starting gun
227
+ threads.map(&:value)
228
+ end
229
+
230
+ # The outbox/delegation claim, verbatim in shape: read the status, and only
231
+ # the connection that still sees "pending" may flip it. A correct backend
232
+ # serializes the transactions, so exactly ONE of the 8 observes "pending".
233
+ it "claims a pending key exactly once across concurrent connections" do
234
+ store.set("jobs", "job:1", "pending")
235
+
236
+ claims = with_concurrent_connections(8) do |conn|
237
+ conn.transaction do
238
+ next unless conn.get("jobs", "job:1") == "pending"
239
+
240
+ sleep 0.01 # hold the read open: a wrong backend lets everyone through
241
+ conn.set("jobs", "job:1", "claimed")
242
+ true
243
+ end
244
+ end.count(true)
245
+
246
+ expect(claims).to eq(1)
247
+ expect(store.get("jobs", "job:1")).to eq("claimed")
248
+ end
249
+
250
+ # The lost-update shape the claim generalizes from: read-modify-write of a
251
+ # counter. A backend whose transaction yields without isolation drops
252
+ # increments whenever two connections overlap.
253
+ it "does not lose updates across concurrent connections" do
254
+ store.set("meters", "hits", 0)
255
+ connections = 4
256
+ increments = 10
257
+
258
+ with_concurrent_connections(connections) do |conn|
259
+ increments.times do
260
+ conn.transaction do
261
+ current = conn.get("meters", "hits")
262
+ sleep 0.001 # widen the window a wrong backend loses updates through
263
+ conn.set("meters", "hits", current + 1)
264
+ end
265
+ end
266
+ end
267
+
268
+ expect(store.get("meters", "hits")).to eq(connections * increments)
269
+ end
270
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Cheap token estimate behind an interface.
5
+ # Default: text.length / 4 (off by ~±15%, absorbed by the budget margin).
6
+ # Swappable for a real tokenizer without touching the Builder: any object that
7
+ # responds to #estimate(text) -> Integer works as a replacement (injected at the
8
+ # composition root).
9
+ module TokenEstimator
10
+ module_function
11
+
12
+ def estimate(text)
13
+ text.to_s.length.ceildiv(4)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/semaphore"
5
+
6
+ module Insika
7
+ # Turn-scoped assembly of the agent's tool instances (pipeline stage 3 tail):
8
+ # capability resolution, instantiation (Entry#factory | ready instance),
9
+ # turn-context (D2) injection, the capability<->direct dedup join, and the
10
+ # ToolEnvelope wrap (stage 7 seam: per-call timeout + side-effect recording).
11
+ #
12
+ # Extracted from the Executor (§11 B5) to keep the hot-path file smaller. It is
13
+ # a pure collaborator — it holds only the injected registries/stores and no
14
+ # per-turn state; everything turn-specific arrives via `state`/`turn_context`.
15
+ # The Executor keeps thin delegators (resolve_capabilities/assemble_tool_instances/
16
+ # wrap_tools) so the existing private-method contract stays intact.
17
+ class ToolAssembly
18
+ def initialize(tool_registry:, capability_registry:, event_stream:,
19
+ checkpoint_store:, tool_trace_store:)
20
+ @tool_registry = tool_registry
21
+ @capability_registry = capability_registry
22
+ @event_stream = event_stream
23
+ @checkpoint_store = checkpoint_store
24
+ @tool_trace_store = tool_trace_store
25
+ end
26
+
27
+ # Resolution sub-step BETWEEN Context and Policy —
28
+ # it does NOT feed candidate_tools (those stay ONLY tool_registry.entries,
29
+ # a capability does not go through the ToolAllowlist). Resolves each
30
+ # capability of the profile to the concrete Entry already registered in the
31
+ # tool_registry and keeps the impl_name -> capability_name mapping for the
32
+ # post-Policy join. Errors
33
+ # (Unavailable/Ambiguous, or an unregistered impl) propagate as a
34
+ # CapabilityError -> single capture in `execute` (stage :capability). Without
35
+ # @capability_registry OR without profile.capabilities: {} (parity).
36
+ def resolve_capabilities(profile, context)
37
+ return {} if @capability_registry.nil?
38
+
39
+ Array(profile.capabilities).each_with_object({}) do |cap_name, names|
40
+ provider = @capability_registry.resolve(cap_name, profile: profile, context: context,
41
+ event_stream: @event_stream)
42
+ next if provider.kind == :workflow # exposure to the agent loop is a follow-up
43
+
44
+ entry = @tool_registry.entries.find { |e| e.name == provider.impl_name.to_s }
45
+ if entry.nil?
46
+ raise CapabilityError, "capability '#{cap_name}' resolveu para impl " \
47
+ "'#{provider.impl_name}', not registered in tool_registry"
48
+ end
49
+
50
+ names[entry.name] ||= cap_name.to_s # the 1st capability to claim an impl wins
51
+ end
52
+ end
53
+
54
+ # Joins the direct instances (Policy/ToolAllowlist) with the
55
+ # capability-sourced ones (grant = profile.capabilities — they never went
56
+ # through Policy). Avoids double-exposure: if the SAME impl_name was also
57
+ # allowed directly, the DIRECT instance is discarded — the model sees only the
58
+ # capability alias.
59
+ def assemble_tool_instances(allowed, state)
60
+ names = state.respond_to?(:capability_names) ? (state.capability_names || {}) : {}
61
+ ctx = state.respond_to?(:turn_context) ? state.turn_context : nil
62
+ return instantiate_tools(allowed, ctx) if names.empty?
63
+
64
+ # Dedup by the ENTRY NAME (registry key = impl_name) BEFORE
65
+ # instantiating — the INSTANCE's `.name` (RubyLLM) is not the registration
66
+ # name.
67
+ direct = Array(allowed).reject { |e| e.respond_to?(:name) && names.key?(e.name.to_s) }
68
+ instantiate_tools(direct, ctx) + capability_tool_instances(names, ctx)
69
+ end
70
+
71
+ # Envelopes each allowed tool (per-call timeout + side-effect recording).
72
+ # The system LoadSkill (configure_chat) is NOT enveloped — it is a system
73
+ # tool with no side-effect and of trivial latency.
74
+ def wrap_tools(tools, state, skip_side_effects = [])
75
+ timeout = state.profile.limits[:tool_timeout] || 60
76
+ install_tool_gate(state)
77
+ tools.map do |tool|
78
+ ToolEnvelope.new(tool, state: state, checkpoint_store: @checkpoint_store,
79
+ tool_registry: @tool_registry, timeout: timeout,
80
+ skip_side_effects: skip_side_effects,
81
+ trace_recorder: @tool_trace_store)
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ # D4 (item 30): the model decides the fan-out, so without a cap a batch of 15
88
+ # data-tools is 15 simultaneous requests to the same upstream — which is how
89
+ # one turn earns a 429 for every other turn in the process. Note the contrast
90
+ # with the primitives that already shipped: `spawn_subagents` caps at 8
91
+ # (SubagentGraph.fan_out_cap) and `Tools::Concurrency.gather` takes `max:`.
92
+ #
93
+ # The cap is enforced in OUR decorator, not in the gem (which has none): ONE
94
+ # semaphore per turn, installed here — once, in the turn's own fiber, before
95
+ # any tool can run — and acquired by every ToolEnvelope. Off (nil) unless the
96
+ # turn actually got concurrency, so the serial path allocates nothing.
97
+ # A state that predates these fields (a unit stub) is left alone.
98
+ def install_tool_gate(state)
99
+ return unless state.respond_to?(:tool_gate) && state.respond_to?(:tool_concurrency)
100
+
101
+ cap = state.tool_concurrency
102
+ state.tool_gate = cap ? Async::Semaphore.new(cap) : nil
103
+ end
104
+
105
+ # Real Engine -> Entries (respond to factory); fakes -> ready instances.
106
+ # `turn_context` (D2) is deposited into the instances that expose it
107
+ # (data-tools); the rest ignore it (parity).
108
+ def instantiate_tools(allowed, turn_context = nil)
109
+ Array(allowed).map do |t|
110
+ tool = t.respond_to?(:factory) ? t.factory.call : t
111
+ inject_turn_context(tool, turn_context)
112
+ tool
113
+ end
114
+ end
115
+
116
+ # D2/G3 seam: deposits the turn context into the freshly created instance
117
+ # (same idea as `remember`, which receives tenant/state) BEFORE the
118
+ # ToolEnvelope. Duck-typed: only what exposes `turn_context=` (DataDefinedTool)
119
+ # receives it. nil (a state with no turn_context, e.g. a test stub) -> no-op.
120
+ def inject_turn_context(tool, turn_context)
121
+ return if turn_context.nil?
122
+
123
+ tool.turn_context = turn_context if tool.respond_to?(:turn_context=)
124
+ end
125
+
126
+ # impl_name -> Capability::ResolvedTool(capability_name:), STILL without
127
+ # ToolEnvelope (the call site's wrap_tools wraps the whole set — same
128
+ # order impl -> ResolvedTool -> ToolEnvelope). entry already validated in
129
+ # resolve_capabilities.
130
+ def capability_tool_instances(names, turn_context = nil)
131
+ names.map do |impl_name, capability_name|
132
+ entry = @tool_registry.entries.find { |e| e.name == impl_name }
133
+ tool = entry.factory.call
134
+ inject_turn_context(tool, turn_context)
135
+ Capability::ResolvedTool.new(tool, capability_name: capability_name,
136
+ impl_name: impl_name)
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # "Level 1" (progressive disclosure) view of the tools: just
5
+ # name+description, the analog of SkillCatalog for tools. It does NOT read
6
+ # disk (the tools are already in the ToolRegistry, registered at boot) and does NOT
7
+ # inherit from RubyLLM::Tool — duck typing over `.description` (pure, testable without
8
+ # the gem).
9
+ #
10
+ # The canonical `description` does not live in the Entry (Registry::Entry has no such
11
+ # field): it comes from the tool INSTANCE (`factory.call.description`). That is why the
12
+ # catalog is LAZY: it only instantiates the tools on the first query (`all`), not at
13
+ # boot. A deployment without `tools_deferred` never touches the catalog and pays for
14
+ # no instantiation at all; a broken factory surfaces on first use (where the
15
+ # Executor would also catch it at stage 3), not at construction.
16
+ class ToolCatalog
17
+ Entry = Data.define(:name, :description)
18
+
19
+ def initialize(tool_registry:)
20
+ @tool_registry = tool_registry
21
+ end
22
+
23
+ def all
24
+ @entries ||= build_entries
25
+ end
26
+
27
+ # Reloads the index (after authoring a data-tool in the overlay). Mirrors
28
+ # SkillCatalog#reload — level-1/tool_search starts seeing the new tool without a
29
+ # restart. An in-flight turn has already captured `all`.
30
+ def reload
31
+ @entries = build_entries
32
+ self
33
+ end
34
+
35
+ # Allowed deferred slice (typically allowed_tools ∩ tools_deferred).
36
+ # Names outside the catalog are silently ignored (safe failure: less
37
+ # exposure, never more).
38
+ def subset(names)
39
+ wanted = Array(names).map(&:to_s)
40
+ all.select { |e| wanted.include?(e.name) }
41
+ end
42
+
43
+ # PURE matcher: case-insensitive, substring/keyword, NO
44
+ # embeddings. name weighs 2, description weighs 1; ties broken by original index
45
+ # (Ruby's sort_by is not stable). `within:` restricts the universe via subset.
46
+ def search(query, within: nil)
47
+ terms = query.to_s.downcase.split(/\s+/).reject(&:empty?)
48
+ return [] if terms.empty?
49
+
50
+ universe = within ? subset(within) : all
51
+ scored = universe.each_with_index.filter_map do |entry, idx|
52
+ score = score_entry(entry, terms)
53
+ [entry, score, idx] if score.positive?
54
+ end
55
+ scored.sort_by { |_entry, score, idx| [-score, idx] }.map(&:first)
56
+ end
57
+
58
+ # Level 1 injected into the prompt — mirror of SkillCatalog#format_for_prompt,
59
+ # swapping the tag and the final instruction (load_skill -> tool_search).
60
+ def format_for_prompt(entries = all)
61
+ return "" if entries.empty?
62
+
63
+ lines = entries.map { |e| %( <tool name="#{e.name}">#{e.description}</tool>) }.join("\n")
64
+
65
+ <<~PROMPT.strip
66
+ <available_tools>
67
+ #{lines}
68
+ </available_tools>
69
+
70
+ Before using a tool above, call `tool_search` with what you need
71
+ to do to enable it in this conversation.
72
+ PROMPT
73
+ end
74
+
75
+ private
76
+
77
+ def build_entries
78
+ @tool_registry.entries.map do |entry|
79
+ Entry.new(name: entry.name, description: entry.factory.call.description.to_s)
80
+ end
81
+ end
82
+
83
+ def score_entry(entry, terms)
84
+ name = entry.name.downcase
85
+ desc = entry.description.downcase
86
+ terms.sum { |term| (name.include?(term) ? 2 : 0) + (desc.include?(term) ? 1 : 0) }
87
+ end
88
+ end
89
+ end