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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Durable record of one pending OUTBOUND reply. A Shape B
8
+ # channel answers out of band — the turn ends and the reply is POSTed to the
9
+ # platform (or, for a relay, to the consumer's own callback) afterwards — so
10
+ # "the answer exists but the recipient is not on this connection" is exactly
11
+ # the problem already solved for async delegation. This store
12
+ # is DelegationStore's shape with a different recipient, deliberately: a second
13
+ # invention here would be a second thing to get wrong.
14
+ #
15
+ # Lifecycle (never backwards):
16
+ # pending the turn committed a reply; nobody has claimed it yet. This is
17
+ # the ONLY status the boot sweep re-drives.
18
+ # delivering claimed. The claim happens BEFORE the HTTP call, so a crash
19
+ # here loses the delivery rather than duplicating it —
20
+ # at-most-once, the same honest scope as the delegation path.
21
+ # delivered the recipient answered 2xx.
22
+ # failed the bounded retry ran out. Terminal, and NOT re-driven at boot:
23
+ # a third party that refused N times is an operator problem, not
24
+ # something to replay forever.
25
+ #
26
+ # `failed` and `delivering` are a deliberate widening of the RFC's two-status
27
+ # sketch (`pending -> delivered`): without them a crashed claim and an
28
+ # exhausted retry are indistinguishable from a fresh record, and the sweep
29
+ # would redeliver both.
30
+ #
31
+ # Normalizes symbol->string on WRITE (the backend only round-trips JSON types),
32
+ # like every other domain store.
33
+ class OutboxStore
34
+ SCOPE = "outbox"
35
+ KEY_PREFIX = "outbox:"
36
+
37
+ STATUSES = %i[pending delivering delivered failed].freeze
38
+
39
+ Delivery = Data.define(
40
+ :id, :channel, :to, :task_id, :session_id, :payload,
41
+ :status, :attempts, :last_error, :created_at, :updated_at
42
+ )
43
+
44
+ def initialize(store:)
45
+ @store = store
46
+ end
47
+
48
+ # -> Delivery (:pending). `payload` is the body the channel will send; it is
49
+ # DATA (string keys, JSON types) and the store never interprets it.
50
+ def create(channel:, to:, task_id:, session_id:, payload:, id: SecureRandom.uuid)
51
+ record = {
52
+ "id" => id.to_s,
53
+ "channel" => channel.to_s,
54
+ "to" => to.to_s,
55
+ "task_id" => task_id&.to_s,
56
+ "session_id" => session_id&.to_s,
57
+ "payload" => payload,
58
+ "status" => "pending",
59
+ "attempts" => 0,
60
+ "last_error" => nil,
61
+ "created_at" => timestamp,
62
+ "updated_at" => timestamp
63
+ }
64
+ @store.set(SCOPE, key_for(id), record)
65
+ to_delivery(record)
66
+ end
67
+
68
+ # -> Delivery | nil
69
+ def find(id)
70
+ record = @store.get(SCOPE, key_for(id))
71
+ record && to_delivery(record)
72
+ end
73
+
74
+ # -> [Delivery] still waiting for a first claim (boot sweep). Deliberately NOT
75
+ # `delivering`: that one was claimed by a process that then died, and whether
76
+ # its POST landed is unknowable — replaying it is the duplicate the claim
77
+ # exists to prevent.
78
+ def pending
79
+ scan.select { |d| d.status == :pending }
80
+ end
81
+
82
+ # pending -> delivering, ATOMICALLY — across processes, not just fibers: the
83
+ # read-check-write rides Store#transaction, so two workers claiming the same
84
+ # record serialize on the backend's lock and only one sees :pending. Returns
85
+ # true only for the caller that won the transition; that caller (and only it)
86
+ # makes the HTTP call, so delivery is at-most-once even if the terminal hook
87
+ # and the boot sweep both fire.
88
+ def claim(id)
89
+ @store.transaction do
90
+ record = @store.get(SCOPE, key_for(id))
91
+ next false unless record && record["status"] == "pending"
92
+
93
+ record["status"] = "delivering"
94
+ record["updated_at"] = timestamp
95
+ @store.set(SCOPE, key_for(id), record)
96
+ true
97
+ end
98
+ end
99
+
100
+ # One attempt happened and did not succeed. Keeps the record :delivering (the
101
+ # in-process retry loop owns it) and writes down why, so an operator reading
102
+ # the store sees the third party's answer and not just a counter.
103
+ def record_attempt(id, error: nil)
104
+ record = fetch!(id)
105
+ record["attempts"] = record["attempts"].to_i + 1
106
+ record["last_error"] = error&.to_s
107
+ touch(id, record)
108
+ end
109
+
110
+ # -> Delivery (:delivered). Idempotent: an already-delivered record is
111
+ # returned unchanged rather than re-marked.
112
+ def mark_delivered(id)
113
+ record = fetch!(id)
114
+ return to_delivery(record) if record["status"] == "delivered"
115
+
116
+ record["status"] = "delivered"
117
+ record["attempts"] = record["attempts"].to_i + 1
118
+ record["last_error"] = nil
119
+ touch(id, record)
120
+ end
121
+
122
+ # -> Delivery (:failed). The retry budget is spent; nothing re-drives this.
123
+ def mark_failed(id, error: nil)
124
+ record = fetch!(id)
125
+ record["status"] = "failed"
126
+ record["last_error"] = error&.to_s if error
127
+ touch(id, record)
128
+ end
129
+
130
+ private
131
+
132
+ def scan
133
+ return enum_for(:scan) unless block_given?
134
+
135
+ @store.list(SCOPE, KEY_PREFIX).each do |key|
136
+ record = @store.get(SCOPE, key)
137
+ yield to_delivery(record) if record
138
+ end
139
+ end
140
+
141
+ def fetch!(id)
142
+ @store.get(SCOPE, key_for(id)) ||
143
+ (raise Insika::NotFoundError, "outbox delivery not found: #{id}")
144
+ end
145
+
146
+ def touch(id, record)
147
+ record["updated_at"] = timestamp
148
+ @store.set(SCOPE, key_for(id), record)
149
+ to_delivery(record)
150
+ end
151
+
152
+ def key_for(id) = "#{KEY_PREFIX}#{id}"
153
+
154
+ def to_delivery(record)
155
+ Delivery.new(
156
+ id: record["id"], channel: record["channel"], to: record["to"],
157
+ task_id: record["task_id"], session_id: record["session_id"],
158
+ payload: record["payload"], status: record["status"].to_sym,
159
+ attempts: record["attempts"].to_i, last_error: record["last_error"],
160
+ created_at: record["created_at"], updated_at: record["updated_at"]
161
+ )
162
+ end
163
+
164
+ def timestamp = Time.now.utc.iso8601
165
+ end
166
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # DYNAMIC tool registry: composes the CODE registry (base, built at boot,
5
+ # immutable) with the DATA-DEFINED tools from the ToolStore. Drop-in for ToolRegistry —
6
+ # the Executor/ToolCatalog/ToolEnvelope only use entries/resolve/side_effect?.,
7
+ #
8
+ # Rules:
9
+ # - COLLISION: the base (code) ALWAYS wins — a data-tool cannot hijack
10
+ # the name of a code tool (security, R3). The authoring Command also
11
+ # refuses to create with a colliding name (code_tool?), but the defense stays here.
12
+ # - HOT: `reload` re-reads the store and swaps the dynamic index atomically — a
13
+ # new/edited data-tool takes effect on the next turn without a restart, mirroring
14
+ # SkillCatalog.reload. An in-flight turn has already captured the index.
15
+ # PARITY: empty ToolStore ⇒ entries/resolve/side_effect? identical to the
16
+ # pure base. The base (config/wiring.rb) does not even use the overlay — zero regression.
17
+ #
18
+ # The data-tools enter as NORMAL Registry::Entry (optional: false) — they obey
19
+ # the same per-agent allow/deny as code tools; exposure is the operator's
20
+ # (the /tools matrix), not automatic just because they are "data-defined".
21
+ class OverlayToolRegistry
22
+ def initialize(base:, tool_store:, http:, egress: Insika::EgressGuard, egress_options: {}, event_stream: nil)
23
+ @base = base
24
+ @tool_store = tool_store
25
+ @http = http
26
+ @egress = egress
27
+ @egress_options = egress_options
28
+ @event_stream = event_stream
29
+ end
30
+
31
+ # Base + dynamic, except dynamic ones that collide with the base (base wins).
32
+ def entries
33
+ @base.entries + dynamic.reject { |e| code_tool?(e.name) }
34
+ end
35
+
36
+ def names
37
+ (@base.names + dynamic.map(&:name)).uniq
38
+ end
39
+
40
+ # -> instance (base wins) | raise NotFoundError.
41
+ def resolve(name)
42
+ key = name.to_s
43
+ return @base.resolve(key) if code_tool?(key)
44
+
45
+ entry = dynamic.find { |e| e.name == key }
46
+ raise Insika::NotFoundError, "'#{name}' not registered in #{self.class}" unless entry
47
+
48
+ entry.factory.call
49
+ end
50
+
51
+ # -> bool; consumed by ToolEnvelope (checkpoint/skip-on-resume). Base wins.
52
+ def side_effect?(name)
53
+ key = name.to_s
54
+ return @base.side_effect?(key) if code_tool?(key)
55
+
56
+ entry = dynamic.find { |e| e.name == key }
57
+ entry ? !!entry.metadata[:side_effect] : false
58
+ end
59
+
60
+ # Atomic swap of the dynamic index (after writing/removing a data-tool).
61
+ def reload
62
+ @dynamic = build_dynamic
63
+ self
64
+ end
65
+
66
+ # Is it a CODE tool (base)? Used by the authoring Command's validation.
67
+ def code_tool?(name) = @base.names.include?(name.to_s)
68
+
69
+ private
70
+
71
+ def dynamic
72
+ @dynamic ||= build_dynamic
73
+ end
74
+
75
+ def build_dynamic
76
+ @tool_store.all_raw.filter_map { |raw| entry_for(raw) }
77
+ end
78
+
79
+ def entry_for(raw)
80
+ definition = Insika::ToolDefinition.from_h(raw)
81
+ Insika::Registry::Entry.new(
82
+ name: definition.name, plugin: "data-tools",
83
+ metadata: { optional: false, side_effect: definition.side_effect,
84
+ group: definition.group, tags: definition.tags },
85
+ factory: -> { build_tool(definition) }
86
+ )
87
+ rescue Insika::ValidationError => e
88
+ warn "[overlay-tools] invalid definition ignored: #{e.message}"
89
+ nil
90
+ end
91
+
92
+ # Lazy require: DataDefinedTool inherits from RubyLLM::Tool (pulls in the gem) -> kept
93
+ # out of insika.rb load-time, loaded on the 1st instance (turn time).
94
+ def build_tool(definition)
95
+ require_relative "tools/data_defined_tool"
96
+ Insika::Tools::DataDefinedTool.new(
97
+ definition: definition, http: @http, egress: @egress,
98
+ egress_options: @egress_options, event_stream: @event_stream
99
+ )
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ # Provisioning pack: the PORTABLE form of an agent — a
7
+ # manifest + the prompt files + the skills + the data-tool defs. It's what
8
+ # `docs/prompt-base/06` describes as a workspace, here in a value object
9
+ # consumable by the PackImporter (which emits the authoring Commands). GENERIC
10
+ # per project: the engine doesn't know any consumer — the pack is the contract.
11
+ #
12
+ # config: Hash — manifest (AgentProfile.build attrs: id/model/provider/
13
+ # limits/metadata/tools_deferred/…). `id`/`model` required there.
14
+ # files: { "IDENTITY.md" => "<content>", ... } — prompt files (become
15
+ # prompt_files via write_agent_file). File name = key.
16
+ # skills: { "escalation-to-human" => "<SKILL.md>", ... } — 1 per skill.
17
+ # tools: [ { ToolDefinition hash }, ... ] — the pack's data-tool defs.
18
+ #
19
+ # Two sources: `from_h` (GatewayClient JSON — provisioning API) and
20
+ # `from_dir` (a folder on disk per docs/prompt-base/06 — authoring/CLI).
21
+ Pack = Data.define(:config, :files, :skills, :tools) do
22
+ # Raw Hash (string|symbol keys) -> Pack. Tolerates both key conventions
23
+ # (the JSON wire may arrive symbolized or not).
24
+ def self.from_h(hash)
25
+ h = hash || {}
26
+ new(
27
+ config: symbolize(dig(h, :config) || {}),
28
+ files: text_values!(stringify_keys(dig(h, :files) || {}), "files"),
29
+ skills: text_values!(stringify_keys(dig(h, :skills) || {}), "skills"),
30
+ tools: Array(dig(h, :tools))
31
+ )
32
+ end
33
+
34
+ # A pack's files and skills are NAME => MARKDOWN. Only the keys used to be
35
+ # normalized, so `{"files": {"AGENTS.md": {"content": "…"}}}` — the shape anyone
36
+ # writes first — was accepted and reached the store as Ruby's #inspect of the
37
+ # object. Refuse at the front door, where the name of the offending file is still
38
+ # in hand.
39
+ def self.text_values!(hash, field)
40
+ hash.each do |name, value|
41
+ next if value.is_a?(String)
42
+
43
+ raise Insika::ValidationError,
44
+ "pack #{field}['#{name}'] must be the file's text, got #{value.class} — " \
45
+ "a nested object (e.g. {\"content\": …}) is not unwrapped, it is stored verbatim"
46
+ end
47
+ end
48
+ private_class_method :text_values!
49
+
50
+ # Folder on disk (docs/prompt-base/06): `agent.config.json` + `*.md` at the
51
+ # root + `skills/<name>/SKILL.md` + `tools/*.json` (1 ToolDefinition per file).
52
+ def self.from_dir(path)
53
+ root = path.to_s
54
+ config_file = File.join(root, "agent.config.json")
55
+ raise Insika::ValidationError, "pack missing agent.config.json in #{root}" unless File.exist?(config_file)
56
+
57
+ new(
58
+ config: symbolize(JSON.parse(File.read(config_file, encoding: "UTF-8"))),
59
+ files: read_md_files(root),
60
+ skills: read_skills(File.join(root, "skills")),
61
+ tools: read_tools(File.join(root, "tools"))
62
+ )
63
+ end
64
+
65
+ def self.dig(hash, key) = hash[key] || hash[key.to_s]
66
+ private_class_method :dig
67
+
68
+ def self.symbolize(hash)
69
+ (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
70
+ end
71
+ private_class_method :symbolize
72
+
73
+ def self.stringify_keys(hash)
74
+ (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
75
+ end
76
+ private_class_method :stringify_keys
77
+
78
+ # `*.md` at the pack ROOT (doesn't descend into skills/). File name = key.
79
+ def self.read_md_files(root)
80
+ Dir.glob(File.join(root, "*.md")).sort.each_with_object({}) do |file, acc|
81
+ acc[File.basename(file)] = File.read(file, encoding: "UTF-8")
82
+ end
83
+ end
84
+ private_class_method :read_md_files
85
+
86
+ # `skills/<name>/SKILL.md` -> { "<name>" => "<content>" }.
87
+ def self.read_skills(dir)
88
+ Dir.glob(File.join(dir, "*", "SKILL.md")).sort.each_with_object({}) do |file, acc|
89
+ acc[File.basename(File.dirname(file))] = File.read(file, encoding: "UTF-8")
90
+ end
91
+ end
92
+ private_class_method :read_skills
93
+
94
+ # `tools/*.json` -> [ ToolDefinition hash, ... ].
95
+ def self.read_tools(dir)
96
+ Dir.glob(File.join(dir, "*.json")).sort.map do |file|
97
+ JSON.parse(File.read(file, encoding: "UTF-8"))
98
+ end
99
+ end
100
+ private_class_method :read_tools
101
+ end
102
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Pack importer: reads a Pack and emits the ALREADY
5
+ # existing authoring Commands — create_agent/update_agent + write_agent_file +
6
+ # write_skill + write_data_tool — making an agent provisionable at runtime from
7
+ # a standardized pack. It's the piece a consumer's provisioning client
8
+ # triggers (via the provisioning API).
9
+ #
10
+ # GENERIC: nothing here mentions a consumer — the pack is the contract. Tool
11
+ # names come from the PACK, so prompts<->tools stay consistent by construction
12
+ # It doesn't write to the store directly: it only dispatches Commands on
13
+ # the bus (the same transport discipline) + READS the ProfileSource to decide
14
+ # create vs update.
15
+ #
16
+ # IDEMPOTENT (upsert): re-importing the same pack reconciles — files/skills/
17
+ # tools rewritten; the allowlists (prompt_files/skills/tools_allow) are
18
+ # AUTHORITATIVE from the pack, so whatever left the pack leaves the agent
19
+ # (isolation and no drift). An in-flight turn keeps the profile it captured.
20
+ class PackImporter
21
+ def initialize(bus:, profiles:)
22
+ @bus = bus
23
+ @profiles = profiles
24
+ end
25
+
26
+ # -> { agent_id:, created:, files: [names], skills: [names], tools: [names] }.
27
+ # Validation/lookup errors from the Commands (missing id/model, etc.) propagate
28
+ # (the transport maps them to 422/404).
29
+ def import(pack)
30
+ id = presence(pack.config[:id]) ||
31
+ (raise Insika::ValidationError, "pack missing config.id")
32
+
33
+ created = @profiles[id].nil?
34
+ # create_agent rejects an already-existing id; update_agent requires it to
35
+ # exist — the choice by presence makes the import an upsert.
36
+ dispatch(created ? :create_agent : :update_agent, agent_attrs(pack, id))
37
+
38
+ pack.files.each { |name, body| dispatch(:write_agent_file, { agent_id: id, file: name, content: body }) }
39
+ pack.skills.each { |name, body| dispatch(:write_skill, { name: name, content: body }) }
40
+ pack.tools.each { |defn| dispatch(:write_data_tool, defn) }
41
+
42
+ { agent_id: id, created: created,
43
+ files: pack.files.keys, skills: pack.skills.keys, tools: pack.tools.map { |t| tool_name(t) } }
44
+ end
45
+
46
+ # Removes the agent (delete_agent). Does NOT delete skills/tools/files: they
47
+ # may be shared and the SkillStore/ToolStore are global — selective removal is
48
+ # operator work. NotFoundError (missing agent) propagates -> 404.
49
+ def delete(id)
50
+ dispatch(:delete_agent, { id: id })
51
+ { agent_id: id, deleted: true }
52
+ end
53
+
54
+ private
55
+
56
+ # AgentProfile.build attrs: the pack manifest + the allowlists derived and
57
+ # AUTHORITATIVE from the pack. This way the agent only sees the skills/tools of
58
+ # its OWN pack (per-store isolation) and re-provisioning removes what left.
59
+ # - prompt_files = the pack's .md files (write_agent_file also registers;
60
+ # union is a no-op). Setting here makes the list authoritative (removes the ones that left).
61
+ # - skills = the pack's skills/ dirs (explicit allowlist; [] when the pack
62
+ # has none — never nil=all, which would leak skills from other stores).
63
+ # - tools_allow = (config.tools_allow) ∪ (the pack's tool names) — guarantees
64
+ # the agent can call its own data-tools. [] when neither exists —
65
+ # never nil=all: the ToolStore is GLOBAL, so a tool-less pack would
66
+ # otherwise see every other store's tools (store A's pack calling
67
+ # store B's set_location is exactly that leak).
68
+ # - tools_allow_groups = groups enabled by FLAG in the pack (see
69
+ # #enabled_groups) — the per-flag schema CUT: only
70
+ # the tools of the enabled groups (union with tools_allow) go to the model;
71
+ # those of disabled groups are cut BEFORE the turn (resolves the OpenClaw
72
+ # tool-call waste, where the flag only exists in Rails).
73
+ def agent_attrs(pack, id)
74
+ attrs = pack.config.dup
75
+ attrs[:id] = id
76
+ attrs[:prompt_files] = pack.files.keys unless pack.files.empty?
77
+ attrs[:skills] = pack.skills.keys
78
+
79
+ pack_tools = pack.tools.map { |t| tool_name(t) }
80
+ allow = Array(pack.config[:tools_allow]).map(&:to_s) | pack_tools
81
+ attrs[:tools_allow] = allow
82
+
83
+ groups = enabled_groups(pack.config)
84
+ attrs[:tools_allow_groups] = groups unless groups.nil?
85
+ attrs
86
+ end
87
+
88
+ # PER-FLAG CUT (/, STATIC pilot): derives the agent's
89
+ # per-group allowlist from FLAGS declared in the pack config — DATA, never a
90
+ # core convention. The engine doesn't know "groceries_v2"/"b2b": the pack
91
+ # declares which GROUPS are enabled; the flag->group mapping is the
92
+ # responsibility of provisioning/the pack, not the insika. Two forms (union):
93
+ # - `enabled_groups: ["default", "b2b"]` — explicit list of ON groups.
94
+ # - `flags: { "wholesale" => true, "outlet" => false }` — the flag key IS the group
95
+ # name; only the truthy ones enter (the false ones CUT the group).
96
+ # Neither declared -> nil (no per-group cut; old behavior).
97
+ # Explicit `[]` (empty enabled_groups, or all flags false) -> no group.
98
+ def enabled_groups(config)
99
+ return nil unless config.key?(:enabled_groups) || config.key?(:flags)
100
+
101
+ from_list = Array(config[:enabled_groups]).map(&:to_s)
102
+ from_flags = flag_groups(config[:flags])
103
+ (from_list | from_flags)
104
+ end
105
+
106
+ # { group => bool } -> [groups with a truthy value]. Accepts string|symbol keys.
107
+ def flag_groups(flags)
108
+ return [] unless flags.is_a?(Hash)
109
+
110
+ flags.filter_map { |group, on| group.to_s if truthy?(on) }
111
+ end
112
+
113
+ def truthy?(val) = val == true || val.to_s == "true"
114
+
115
+ def tool_name(defn) = (defn[:name] || defn["name"]).to_s
116
+
117
+ def presence(str) = Insika::Coercion.presence(str)
118
+
119
+ def dispatch(type, payload)
120
+ @bus.dispatch(Insika::Command.build(type, payload, transport: :http))
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Domain store for PENDING approval ACTIONS ("state as a
8
+ # record, not a flag"). A tool marked `approval` (Policy) creates a
9
+ # PendingAction and the turn goes to :waiting; the operator resolves it via ApproveAction.
10
+ # Durable (over an injected Insika::Store): survives a kill -9 — the operator
11
+ # approves after the reboot, and Recovery rehydrates the task in :waiting.
12
+ #
13
+ # Normalizes symbol→string on WRITE (the backend only guarantees round-trip of JSON
14
+ # types), like the other domain stores.
15
+ class PendingActionStore
16
+ include Coercion
17
+
18
+ SCOPE = "pending_actions"
19
+ KEY_PREFIX = "pending:"
20
+
21
+ STATUSES = %i[pending approved rejected].freeze
22
+
23
+ PendingAction = Data.define(:id, :task_id, :turn, :tool, :args,
24
+ :status, :requested_at, :resolved_by, :resolved_at)
25
+
26
+ def initialize(store:)
27
+ @store = store
28
+ end
29
+
30
+ # -> PendingAction (:pending). `args` is the Hash of the tool call's arguments.
31
+ def create(task_id:, turn:, tool:, args: {}, id: SecureRandom.uuid)
32
+ record = {
33
+ "id" => id.to_s,
34
+ "task_id" => task_id.to_s,
35
+ "turn" => turn,
36
+ "tool" => tool.to_s,
37
+ "args" => deep_stringify(args),
38
+ "status" => "pending",
39
+ "requested_at" => timestamp,
40
+ "resolved_by" => nil,
41
+ "resolved_at" => nil
42
+ }
43
+ @store.set(SCOPE, key_for(id), record)
44
+ to_pending(record)
45
+ end
46
+
47
+ # -> PendingAction | nil
48
+ def find(id)
49
+ record = @store.get(SCOPE, key_for(id))
50
+ record && to_pending(record)
51
+ end
52
+
53
+ # -> [PendingAction] :pending for the task (recovery/UI). O(n) scan — single-node,
54
+ # like TaskStore#running_or_interrupted.
55
+ def open_for(task_id)
56
+ id = task_id.to_s
57
+ @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
58
+ record = @store.get(SCOPE, key)
59
+ next if record.nil?
60
+
61
+ pa = to_pending(record)
62
+ pa if pa.status == :pending && pa.task_id == id
63
+ end
64
+ end
65
+
66
+ # -> [PendingAction] every :pending across all tasks — the approvals inbox
67
+ # (Studio). Single O(n) scan (vs. open_for per task = O(n·m)); the
68
+ # UI resolves task context afterwards via TaskStore#find.
69
+ def all_open
70
+ @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
71
+ record = @store.get(SCOPE, key)
72
+ next if record.nil?
73
+
74
+ pa = to_pending(record)
75
+ pa if pa.status == :pending
76
+ end
77
+ end
78
+
79
+ # -> resolved PendingAction. Only resolves :pending: a double resolution
80
+ # or an invalid decision -> ValidationError; absent -> NotFoundError.
81
+ def resolve(id, decision:, operator: nil)
82
+ target = decision.to_sym
83
+ unless %i[approved rejected].include?(target)
84
+ raise Insika::ValidationError, "invalid decision: #{decision} (approved|rejected)"
85
+ end
86
+
87
+ record = @store.get(SCOPE, key_for(id)) ||
88
+ (raise Insika::NotFoundError, "pending action not found: #{id}")
89
+ unless record["status"] == "pending"
90
+ raise Insika::ValidationError, "pending action '#{id}' already resolved (#{record["status"]})"
91
+ end
92
+
93
+ record["status"] = target.to_s
94
+ record["resolved_by"] = operator&.to_s
95
+ record["resolved_at"] = timestamp
96
+ @store.set(SCOPE, key_for(id), record)
97
+ to_pending(record)
98
+ end
99
+
100
+ private
101
+
102
+ def key_for(id) = "#{KEY_PREFIX}#{id}"
103
+
104
+ def to_pending(record)
105
+ PendingAction.new(
106
+ id: record["id"],
107
+ task_id: record["task_id"],
108
+ turn: record["turn"],
109
+ tool: record["tool"],
110
+ args: record["args"],
111
+ status: record["status"].to_sym,
112
+ requested_at: record["requested_at"],
113
+ resolved_by: record["resolved_by"],
114
+ resolved_at: record["resolved_at"]
115
+ )
116
+ end
117
+
118
+ def timestamp = Time.now.utc.iso8601
119
+ end
120
+ end