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,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # Canonical turn command: validates everything synchronously, creates the Task, fires
6
+ # the fiber and responds `{task_id:}` immediately — the result flows through the Event
7
+ # Stream. Validations that fail do NOT create a Task
8
+ # (ValidationError/NotFoundError -> direct HTTP response).
9
+ class SendMessage
10
+ # RFC-0015 §5.5 — surfaces whose response can carry the "you do not own the reply"
11
+ # verdict (`merged` for `collect`, `steered` for `steer`), and therefore the only
12
+ # ones where a message may join another turn. `/v1/responses` is NOT here: its body
13
+ # is OpenAI-shaped SSE with nowhere to put the field, and it is frozen because a
14
+ # live consumer speaks it. Joining a caller that cannot hear the verdict makes it
15
+ # deliver the same answer once per message, which is worse than not joining at all.
16
+ # Channels declare themselves by `channel:<id>` once RFC-0011 §6 lands.
17
+ COALESCABLE_TRANSPORTS = %i[http:json].freeze
18
+
19
+ # RFC-0011 §6.4 — `inbound_log` is the retry window for channel event ids.
20
+ # nil = no dedup (every surface that does not send an `event_id`, which is all
21
+ # of them today), and a caller that cannot supply a stable id gets
22
+ # at-least-once turns rather than a content hash pretending to be dedup.
23
+ def initialize(profiles:, session_store:, task_store:, executor:, inbound_log: nil)
24
+ @profiles = ProfileSource.coerce(profiles)
25
+ @session_store = session_store
26
+ @task_store = task_store
27
+ @executor = executor
28
+ @inbound_log = inbound_log
29
+ end
30
+
31
+ def call(command)
32
+ p = normalize(command.payload) # accepts string and symbol keys
33
+
34
+ agent = p[:agent].to_s
35
+ raise Insika::ValidationError, "agent is required" if agent.empty?
36
+
37
+ profile = @profiles[agent] ||
38
+ (raise Insika::NotFoundError, "agent '#{agent}' not configured")
39
+
40
+ message = p[:message]
41
+ raise Insika::ValidationError, "message is required and non-empty" if message.to_s.strip.empty?
42
+
43
+ # session_id XOR history (both -> error; neither -> one-shot).
44
+ if p[:session_id] && p[:history]
45
+ raise Insika::ValidationError, "session_id and history are mutually exclusive (D2)"
46
+ end
47
+
48
+ validate_history!(p[:history]) if p[:history]
49
+ # WHO wrote this message (MessageOrigin). Absent = a customer typed it, which
50
+ # is what a turn has always meant. Refused here rather than downstream: a
51
+ # typo'd origin would read as absent, and a marker that silently means
52
+ # "unmarked" is worse than none — it looks like the filtering is on.
53
+ Insika::MessageOrigin.parse!(p[:origin])
54
+ if p[:session_id]
55
+ @session_store.find(p[:session_id]) ||
56
+ (raise Insika::NotFoundError, "session '#{p[:session_id]}' not found")
57
+ end
58
+
59
+ # RFC-0011 §6.4 — the platform retried a webhook it already delivered. Answer
60
+ # with the turn it ALREADY produced and run nothing: without this, one flaky
61
+ # ack costs a second LLM turn and sends the customer the same answer twice.
62
+ # Checked before the queue doors on purpose — a duplicate is not a fragment to
63
+ # merge and not a correction to steer with, it is the same message again.
64
+ key = dedup_key(command, p)
65
+ if key && (prior = @inbound_log.find(key))
66
+ return { task_id: prior, duplicate: true }
67
+ end
68
+
69
+ result = start_turn(command, p, profile)
70
+ @inbound_log.record(key, result[:task_id]) if key
71
+ result
72
+ end
73
+
74
+ private
75
+
76
+ # The turn (or the verdict that this message joined someone else's).
77
+ def start_turn(command, p, profile)
78
+ message = p[:message]
79
+
80
+ # RFC-0015 §5.3 — a fragment for a session whose turn is still at the door
81
+ # joins it instead of becoming a turn of its own. Asked BEFORE `create` so a
82
+ # merge leaves no orphan :queued task behind (Recovery replays :queued at
83
+ # boot). Only offered on a surface that can report the verdict back —
84
+ # §5.5: coalescing a caller that cannot hear `merged` makes it deliver the
85
+ # same answer twice.
86
+ if coalescable?(command)
87
+ if (joined = @executor.collect_into_pending(p[:session_id], message, profile: profile))
88
+ return { task_id: joined, merged: true }
89
+ end
90
+
91
+ # RFC-0015 §5.1 — the turn is already RUNNING: the message is appended to it at
92
+ # the next tool-batch boundary. Same verdict as a merge, different word: the
93
+ # answer comes out of `task_id`, which is not this call's to deliver. Asked
94
+ # after `collect` because the two cannot both apply — a turn is either still at
95
+ # the door or running.
96
+ if (steered = @executor.steer_into_running(p[:session_id], message, profile: profile))
97
+ return { task_id: steered, steered: true }
98
+ end
99
+ end
100
+
101
+ # command.to_h persists the entire Command in the Task;
102
+ # ResumeTask re-reads payload.message from there.
103
+ task = @task_store.create(command: command.to_h, session_id: p[:session_id])
104
+ # RFC-0015 §6.4 — `interrupt` mode: the turn in flight is now answering the wrong
105
+ # question, so it is abandoned at its next boundary. This message keeps its OWN
106
+ # task and its own reply (that is why it needs no verdict and no surface gate), and
107
+ # the cancel is posted after `create` so the event can name what replaced what.
108
+ # No-op in every other mode.
109
+ @executor.interrupt_running(p[:session_id], profile: profile, replaced_by: task.id)
110
+ @executor.spawn_in_session(task, profile: profile)
111
+ { task_id: task.id }
112
+ end
113
+
114
+ def coalescable?(command)
115
+ transport = command.meta[:transport]
116
+ COALESCABLE_TRANSPORTS.include?(transport) || transport.to_s.start_with?("channel:")
117
+ end
118
+
119
+ # Scoped by TRANSPORT, never bare: two channels are two consumers with two id
120
+ # spaces, and a Slack event id that happened to equal a `wamid` would otherwise
121
+ # silence a real message. nil (no log wired, or no id sent) = no dedup.
122
+ def dedup_key(command, payload)
123
+ return nil unless @inbound_log
124
+
125
+ event_id = Coercion.presence(payload[:event_id])
126
+ event_id && "#{command.meta[:transport]}:#{event_id}"
127
+ end
128
+
129
+ def normalize(payload)
130
+ {
131
+ agent: payload[:agent] || payload["agent"],
132
+ message: payload[:message] || payload["message"],
133
+ session_id: payload[:session_id] || payload["session_id"],
134
+ history: payload[:history] || payload["history"],
135
+ origin: payload[:origin] || payload["origin"],
136
+ event_id: payload[:event_id] || payload["event_id"]
137
+ }
138
+ end
139
+
140
+ def validate_history!(history)
141
+ ok = history.is_a?(Array) && history.all? do |m|
142
+ m.is_a?(Hash) &&
143
+ !m.values_at(:role, "role").compact.empty? &&
144
+ !m.values_at(:content, "content").compact.empty?
145
+ end
146
+ raise Insika::ValidationError, "history must be [{role:, content:}]" unless ok
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: adjusts an agent's tools allow/denylist.
8
+ # `allow` nil = all (AgentProfile rule); `deny` always
9
+ # wins. `allow_groups` (Phase 7/D4/F5, Step C): per-group allowlist, only
10
+ # overwritten if the key comes in the payload (otherwise preserved). Takes effect on the next
11
+ # dispatch (hot). -> AgentProfile.
12
+ class SetAgentTools
13
+ def initialize(profile_source:, event_stream:)
14
+ @profile_source = profile_source
15
+ @event_stream = event_stream
16
+ end
17
+
18
+ def call(command)
19
+ p = AgentPayload.symbolize(command.payload)
20
+ id = AgentPayload.presence(p[:id])
21
+ raise Insika::ValidationError, "id is required" if id.nil?
22
+ raise Insika::ValidationError, "allow must be a list or nil" unless p[:allow].nil? || p[:allow].is_a?(Array)
23
+ raise Insika::ValidationError, "allow_groups must be a list or nil" unless p[:allow_groups].nil? || p[:allow_groups].is_a?(Array)
24
+
25
+ existing = @profile_source.fetch(id) ||
26
+ (raise Insika::NotFoundError, "agent '#{id}' not found")
27
+
28
+ merged = existing.to_h.merge(tools_allow: p[:allow], tools_deny: Array(p[:deny]))
29
+ merged[:tools_allow_groups] = p[:allow_groups] if p.key?(:allow_groups)
30
+ @profile_source.put(Insika::AgentProfile.build(**merged))
31
+ @event_stream.emit(Insika::Event.new(
32
+ type: :agent_tools_set, data: { agent_id: id },
33
+ meta: { at: Time.now.utc.iso8601 }
34
+ ))
35
+ @profile_source.fetch(id)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: enables/disables a skill on N
8
+ # agents at once, adjusting each one's `profile.skills` allowlist.
9
+ # Takes effect on the next dispatch (hot via ProfileSource). -> { name, enabled_for }.
10
+ #
11
+ # Allowlist semantics (AgentProfile): nil = ALL, [] = none,
12
+ # [names] = subset. Important and deliberate consequence:
13
+ # - enabling on an agent with skills=nil: no-op (already has all).
14
+ # - DISABLING on an agent with skills=nil: NOT done here — removing one from
15
+ # "all" would require enumerating the catalog and materializing an explicit
16
+ # allowlist (destructive/surprising). These agents are left intact and
17
+ # go into `skipped_all`. To restrict, use an explicit :set_agent_tools/allowlist
18
+ # first.
19
+ class SetSkillAgents
20
+ def initialize(profile_source:, event_stream:)
21
+ @profile_source = profile_source
22
+ @event_stream = event_stream
23
+ end
24
+
25
+ def call(command)
26
+ p = AgentPayload.symbolize(command.payload)
27
+ name = AgentPayload.presence(p[:name])
28
+ raise Insika::ValidationError, "name is required" if name.nil?
29
+ raise Insika::ValidationError, "agent_ids must be a list" unless p[:agent_ids].nil? || p[:agent_ids].is_a?(Array)
30
+
31
+ wanted = Array(p[:agent_ids]).map(&:to_s)
32
+ enabled_for = []
33
+ skipped_all = []
34
+
35
+ @profile_source.all.each do |profile|
36
+ want = wanted.include?(profile.id)
37
+ new_skills = next_skills(profile.skills, name, want)
38
+
39
+ if new_skills == :skip
40
+ skipped_all << profile.id
41
+ next
42
+ end
43
+ next if new_skills == profile.skills # no change
44
+
45
+ @profile_source.put(Insika::AgentProfile.build(**profile.to_h.merge(skills: new_skills)))
46
+ enabled_for << profile.id if want
47
+ end
48
+
49
+ @event_stream.emit(Insika::Event.new(
50
+ type: :skill_agents_set,
51
+ data: { name: name, agent_ids: wanted, skipped_all: skipped_all },
52
+ meta: { at: Time.now.utc.iso8601 }
53
+ ))
54
+ { name: name, enabled_for: enabled_for, skipped_all: skipped_all }
55
+ end
56
+
57
+ private
58
+
59
+ # -> new allowlist | :skip (agent with nil that requested disable).
60
+ def next_skills(current, name, want)
61
+ if want
62
+ current.nil? ? nil : (Array(current).map(&:to_s) | [name])
63
+ elsif current.nil?
64
+ :skip
65
+ else
66
+ Array(current).map(&:to_s) - [name]
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # Turn command: fires a workflow. Reuses the canonical
6
+ # pipeline — only stage 6 varies (Executor). Validates everything
7
+ # synchronously and responds `{task_id:}` immediately. The workflow allowlist is NOT
8
+ # checked here: it is stage-3 enforcement via WorkflowAllowlist
9
+ # -> PolicyDenied -> task :failed.
10
+ class TriggerWorkflow
11
+ ALLOWED_KEYS = %i[workflow agent input session_id].freeze
12
+
13
+ def initialize(profiles:, session_store:, task_store:, executor:, workflow_registry:)
14
+ @profiles = ProfileSource.coerce(profiles)
15
+ @session_store = session_store
16
+ @task_store = task_store
17
+ @executor = executor
18
+ @workflow_registry = workflow_registry
19
+ end
20
+
21
+ def call(command)
22
+ p = normalize(command.payload)
23
+ reject_unknown_keys!(command.payload) # strict validation
24
+
25
+ workflow = p[:workflow].to_s
26
+ raise Insika::ValidationError, "workflow is required" if workflow.empty?
27
+
28
+ agent = p[:agent].to_s
29
+ raise Insika::ValidationError, "agent is required" if agent.empty?
30
+
31
+ profile = @profiles[agent] ||
32
+ (raise Insika::NotFoundError, "agent '#{agent}' not configured")
33
+
34
+ input = p[:input] || {}
35
+ raise Insika::ValidationError, "input must be a Hash" unless input.is_a?(Hash)
36
+
37
+ if p[:session_id]
38
+ @session_store.find(p[:session_id]) ||
39
+ (raise Insika::NotFoundError, "session '#{p[:session_id]}' not found")
40
+ end
41
+
42
+ # existence is validatable without executing — names, NEVER resolve (don't instantiate
43
+ # outside the fiber).
44
+ unless @workflow_registry.names.include?(workflow)
45
+ raise Insika::NotFoundError, "workflow '#{workflow}' not registered"
46
+ end
47
+
48
+ # I/O by schema (item 22 / §4.4): the INPUT is validated SYNCHRONOUSLY —
49
+ # a non-conforming input is a WorkflowSchemaError (< ValidationError -> 422)
50
+ # with NO run created. `definition` reads the schema metadata without
51
+ # resolving the factory (stays out of the fiber). No-op without an input_schema.
52
+ @workflow_registry.definition(workflow).validate_input!(input)
53
+
54
+ task = @task_store.create(command: command.to_h, session_id: p[:session_id])
55
+ @executor.spawn_in_session(task, profile: profile)
56
+ # runId = the run's durable record, which IS the Task (checkpointed +
57
+ # recoverable). Exposed under both names so the workflow consumer has a
58
+ # stable `run_id` while every task surface (GET /v1/tasks/:id, the event
59
+ # stream) keeps working unchanged.
60
+ { task_id: task.id, run_id: task.id }
61
+ end
62
+
63
+ private
64
+
65
+ def normalize(payload)
66
+ {
67
+ workflow: payload[:workflow] || payload["workflow"],
68
+ agent: payload[:agent] || payload["agent"],
69
+ input: payload[:input] || payload["input"],
70
+ session_id: payload[:session_id] || payload["session_id"]
71
+ }
72
+ end
73
+
74
+ def reject_unknown_keys!(payload)
75
+ extra = payload.keys.map(&:to_sym) - ALLOWED_KEYS
76
+ raise Insika::ValidationError, "unknown keys in payload: #{extra.join(', ')}" unless extra.empty?
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: edits an existing agent. Merges the
8
+ # patch over the current profile (only the sent fields change) + rebuild + put.
9
+ # Takes effect on the NEXT dispatch (the ProfileSource reads fresh). -> AgentProfile.
10
+ class UpdateAgent
11
+ def initialize(profile_source:, event_stream:)
12
+ @profile_source = profile_source
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ patch = AgentPayload.attrs(command.payload)
18
+ id = AgentPayload.presence(patch[:id])
19
+ raise Insika::ValidationError, "id is required" if id.nil?
20
+
21
+ existing = @profile_source.fetch(id) ||
22
+ (raise Insika::NotFoundError, "agent '#{id}' not found")
23
+
24
+ # to_h of the current profile brings the correct symbols; patch overwrites only
25
+ # what was sent. id never changes (rename = create+delete, out of scope).
26
+ merged = existing.to_h.merge(patch).merge(id: id)
27
+ profile = Insika::AgentProfile.build(**merged)
28
+ # RFC-0010 §4.4: definition-time cycle + depth check (an update may ADD
29
+ # subagents to an existing agent). Raises SubagentError before persisting.
30
+ validate_subagent_graph!(profile)
31
+ @profile_source.put(profile)
32
+ @event_stream.emit(Insika::Event.new(
33
+ type: :agent_updated, data: { agent_id: id },
34
+ meta: { at: Time.now.utc.iso8601 }
35
+ ))
36
+ @profile_source.fetch(id)
37
+ end
38
+
39
+ private
40
+
41
+ def validate_subagent_graph!(profile)
42
+ return if Array(profile.subagents).empty?
43
+
44
+ others = @profile_source.all.reject { |p| p.id.to_s == profile.id.to_s }
45
+ Insika::SubagentGraph.validate!(others + [profile])
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: merges a patch into the
8
+ # general settings (timeouts/streaming/compaction) and persists it in the SettingsStore.
9
+ # Only what came in the patch changes; the rest (and the defaults) is preserved. Takes effect on the
10
+ # next turn that reads settings. -> Hash (resulting settings).
11
+ class UpdateSettings
12
+ def initialize(settings_store:, event_stream:)
13
+ @settings_store = settings_store
14
+ @event_stream = event_stream
15
+ end
16
+
17
+ def call(command)
18
+ p = AgentPayload.symbolize(command.payload)
19
+ patch = p[:patch]
20
+ raise Insika::ValidationError, "patch must be an object" unless patch.nil? || patch.is_a?(Hash)
21
+ raise Insika::ValidationError, "empty patch" if patch.nil? || patch.empty?
22
+
23
+ settings = @settings_store.update(patch)
24
+ @event_stream.emit(Insika::Event.new(
25
+ type: :settings_updated,
26
+ data: { keys: patch.keys.map(&:to_s) },
27
+ meta: { at: Time.now.utc.iso8601 }
28
+ ))
29
+ settings
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: creates/edits an LLM provider
8
+ # (api/base_url/auth_header/api_key/models) in the LLMProviderStore and RECONFIGURES
9
+ # RubyLLM at runtime (LLMConfigurator) — swaps key/base without a restart.
10
+ # The `api_key` is sentinel-aware (__OCULTO__ preserves; "" clears; a new string
11
+ # replaces). Returns the MASKED record (the key never comes back in plaintext).
12
+ class UpsertLLMProvider
13
+ def initialize(provider_store:, configurator:, event_stream:)
14
+ @provider_store = provider_store
15
+ @configurator = configurator
16
+ @event_stream = event_stream
17
+ end
18
+
19
+ def call(command)
20
+ p = AgentPayload.symbolize(command.payload)
21
+ masked = @provider_store.upsert(p) # validates `api`; returns masked
22
+
23
+ # Reconfigures only THIS provider (with the real key from the store, already reconciled).
24
+ result = @configurator.apply([@provider_store.get_raw(masked["api"])])
25
+ @event_stream.emit(Insika::Event.new(
26
+ type: :llm_provider_upserted,
27
+ data: { api: masked["api"], applied: result[:applied], skipped: result[:skipped] },
28
+ meta: { at: Time.now.utc.iso8601 }
29
+ ))
30
+ masked
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: creates/edits an MCP instance
8
+ # (transport/command/url/enabled + `env` credentials) in the McpStore. The
9
+ # credentials are sentinel-aware per key (__OCULTO__ preserves; "" clears;
10
+ # a new string replaces). Returns the MASKED record (env never comes back in
11
+ # plaintext). Durable config CRUD — running an MCP client against the
12
+ # instance is later runtime.
13
+ class UpsertMcp
14
+ def initialize(mcp_store:, event_stream:)
15
+ @mcp_store = mcp_store
16
+ @event_stream = event_stream
17
+ end
18
+
19
+ def call(command)
20
+ p = AgentPayload.symbolize(command.payload)
21
+ masked = @mcp_store.upsert(p) # validates `name`; returns masked
22
+
23
+ @event_stream.emit(Insika::Event.new(
24
+ type: :mcp_upserted,
25
+ data: { name: masked["name"], enabled: masked["enabled"] },
26
+ meta: { at: Time.now.utc.iso8601 }
27
+ ))
28
+ masked
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: writes an agent's prompt file to the
8
+ # workspace (AgentFileStore). This is what gives each BIA its own identity — the
9
+ # Prompt provider reads these files via `profile.prompt_files`. Takes effect
10
+ # on the next dispatch (hot). -> { agent_id, file, updated_at }.
11
+ class WriteAgentFile
12
+ def initialize(profile_source:, agent_file_store:, event_stream:)
13
+ @profile_source = profile_source
14
+ @agent_files = agent_file_store
15
+ @event_stream = event_stream
16
+ end
17
+
18
+ def call(command)
19
+ p = AgentPayload.symbolize(command.payload)
20
+ agent_id = AgentPayload.presence(p[:agent_id])
21
+ file = AgentPayload.presence(p[:file])
22
+ raise Insika::ValidationError, "agent_id is required" if agent_id.nil?
23
+ raise Insika::ValidationError, "file is required" if file.nil?
24
+ profile = @profile_source.fetch(agent_id) ||
25
+ (raise Insika::NotFoundError, "agent '#{agent_id}' not found")
26
+
27
+ # `.to_s` used to be unconditional here, which is how a JSON body carrying an
28
+ # OBJECT for `content` became a prompt file holding Ruby's #inspect of it. The
29
+ # store refuses that now; keep the coercion for scalars (a form always sends a
30
+ # String, and nil means "clear the file").
31
+ content = Insika::AgentFileStore.text!(p[:content], agent_id, file).to_s
32
+ entry = @agent_files.write(agent_id, file, content, create_only: !!p[:create_only])
33
+ # Writing a prompt registers it in prompt_files — otherwise the Prompt provider
34
+ # would never load it. It's the Command's job, not the dispatcher's.
35
+ register_prompt_file(profile, file)
36
+ emit(:agent_file_written, agent_id, file)
37
+ { agent_id: agent_id, file: file, updated_at: entry["updated_at"] }
38
+ end
39
+
40
+ private
41
+
42
+ def register_prompt_file(profile, file)
43
+ files = Array(profile.prompt_files).map(&:to_s)
44
+ return if files.include?(file)
45
+
46
+ @profile_source.put(Insika::AgentProfile.build(**profile.to_h.merge(prompt_files: files + [file])))
47
+ end
48
+
49
+ def emit(type, agent_id, file)
50
+ @event_stream.emit(Insika::Event.new(
51
+ type: type, data: { agent_id: agent_id, file: file },
52
+ meta: { at: Time.now.utc.iso8601 }
53
+ ))
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Writes (upserts) a DATA-defined tool into the ToolStore and RELOADS the overlay +
8
+ # catalog — takes effect without a restart (F5). Rejects a name that collides with a
9
+ # code tool (R3). Validating the definition (format, method/url, types,
10
+ # placeholders) is the ToolStore/ToolDefinition's job. Secrets (headers) reconcile
11
+ # in the store; the event carries only the name (0 leakage). -> { name, definition }.
12
+ class WriteDataTool
13
+ def initialize(tool_store:, registry:, tool_catalog:, event_stream:)
14
+ @tool_store = tool_store
15
+ @registry = registry
16
+ @tool_catalog = tool_catalog
17
+ @event_stream = event_stream
18
+ end
19
+
20
+ def call(command)
21
+ p = AgentPayload.symbolize(command.payload)
22
+ name = AgentPayload.presence(p[:name])
23
+ raise Insika::ValidationError, "name is required" if name.nil?
24
+ raise Insika::ValidationError, "'#{name}' is already a code tool" if @registry.code_tool?(name)
25
+
26
+ masked = @tool_store.write(p, create_only: !!p[:create_only])
27
+ @registry.reload
28
+ @tool_catalog.reload
29
+ emit(name)
30
+ { name: name, definition: masked }
31
+ end
32
+
33
+ private
34
+
35
+ def emit(name)
36
+ @event_stream.emit(Insika::Event.new(
37
+ type: :data_tool_written, data: { name: name },
38
+ meta: { at: Time.now.utc.iso8601 }
39
+ ))
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: writes an eval case (RFC-0008 §3.1) into the GoldenStore. This
8
+ # is what lets a domain owner add a case — above all its RUBRIC, the part of an eval
9
+ # that is plain language — without a checkout and a text editor.
10
+ #
11
+ # The payload carries the case as DATA (`case:`), the same mapping the YAML file
12
+ # holds; the store validates it with the one loader, so a case authored here and a
13
+ # case read from `evals/golden/` cannot diverge. -> { id, agent }.
14
+ class WriteGolden
15
+ def initialize(golden_store:, event_stream:)
16
+ @goldens = golden_store
17
+ @event_stream = event_stream
18
+ end
19
+
20
+ def call(command)
21
+ p = AgentPayload.symbolize(command.payload)
22
+ raise Insika::ValidationError, "case is required" unless p[:case].is_a?(Hash)
23
+
24
+ golden = @goldens.write(p[:case])
25
+ emit(:golden_written, id: golden.id, agent: golden.agent)
26
+ { id: golden.id, agent: golden.agent }
27
+ end
28
+
29
+ private
30
+
31
+ def emit(type, **data)
32
+ @event_stream.emit(Insika::Event.new(type: type, data: data,
33
+ meta: { at: Time.now.utc.iso8601 }))
34
+ end
35
+ end
36
+
37
+ # Control command: removes an eval case. The corpus on disk is the way back
38
+ # (`insika evals:import`), which is why this needs no version history.
39
+ # -> { id, existed }.
40
+ class DeleteGolden
41
+ def initialize(golden_store:, event_stream:)
42
+ @goldens = golden_store
43
+ @event_stream = event_stream
44
+ end
45
+
46
+ def call(command)
47
+ p = AgentPayload.symbolize(command.payload)
48
+ id = AgentPayload.presence(p[:id])
49
+ raise Insika::ValidationError, "id is required" if id.nil?
50
+
51
+ existed = @goldens.delete(id)
52
+ @event_stream.emit(Insika::Event.new(type: :golden_deleted, data: { id: id, existed: existed },
53
+ meta: { at: Time.now.utc.iso8601 }))
54
+ { id: id, existed: existed }
55
+ end
56
+ end
57
+ end
58
+ end