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,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: restores an old version of a
8
+ # prompt file as the current content (a new write — history is
9
+ # linear, no rewinding). `version` = index into `versions` (0 = most recent
10
+ # old one). -> { agent_id, file, updated_at }.
11
+ class RestoreAgentFile
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
+ raise Insika::ValidationError, "version is required" if p[:version].nil?
25
+
26
+ entry = @agent_files.restore(agent_id, file, p[:version])
27
+ @event_stream.emit(Insika::Event.new(
28
+ type: :agent_file_restored,
29
+ data: { agent_id: agent_id, file: file, version: Integer(p[:version]) },
30
+ meta: { at: Time.now.utc.iso8601 }
31
+ ))
32
+ { agent_id: agent_id, file: file, updated_at: entry["updated_at"] }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Restores an old version of a data tool (a new write) and reloads
8
+ # overlay + catalog. The ToolStore raises NotFound/Validation. -> { name,
9
+ # definition (masked) }.
10
+ class RestoreDataTool
11
+ def initialize(tool_store:, registry:, tool_catalog:, event_stream:)
12
+ @tool_store = tool_store
13
+ @registry = registry
14
+ @tool_catalog = tool_catalog
15
+ @event_stream = event_stream
16
+ end
17
+
18
+ def call(command)
19
+ p = AgentPayload.symbolize(command.payload)
20
+ name = AgentPayload.presence(p[:name])
21
+ raise Insika::ValidationError, "name is required" if name.nil?
22
+
23
+ masked = @tool_store.restore(name, p[:index])
24
+ @registry.reload
25
+ @tool_catalog.reload
26
+ @event_stream.emit(Insika::Event.new(
27
+ type: :data_tool_restored, data: { name: name },
28
+ meta: { at: Time.now.utc.iso8601 }
29
+ ))
30
+ { name: name, definition: masked }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: restores an old version of a
8
+ # global system file as the current content (a new write — linear
9
+ # history). -> { file, updated_at }.
10
+ class RestoreSystemFile
11
+ def initialize(system_file_store:, event_stream:)
12
+ @system_files = system_file_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ p = AgentPayload.symbolize(command.payload)
18
+ file = AgentPayload.presence(p[:file])
19
+ raise Insika::ValidationError, "file is required" if file.nil?
20
+ raise Insika::ValidationError, "version is required" if p[:version].nil?
21
+
22
+ entry = @system_files.restore(file, p[:version])
23
+ @event_stream.emit(Insika::Event.new(
24
+ type: :system_file_restored, data: { file: file, version: p[:version].to_s },
25
+ meta: { at: Time.now.utc.iso8601 }
26
+ ))
27
+ { file: file, updated_at: entry["updated_at"] }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # Turn command: resumes a task from the latest checkpoint. Crash-recovery
6
+ # and manual resume both use THIS same path (Recovery only discovers and dispatches).
7
+ # Re-executes the entire checkpointed turn; non-idempotent tools that already
8
+ # completed are skipped via the side-effect registry.
9
+ class ResumeTask
10
+ def initialize(profiles:, task_store:, checkpoint_store:, executor:)
11
+ @profiles = ProfileSource.coerce(profiles)
12
+ @task_store = task_store
13
+ @checkpoint_store = checkpoint_store
14
+ @executor = executor
15
+ end
16
+
17
+ def call(command)
18
+ task_id = (command.payload[:task_id] || command.payload["task_id"]).to_s
19
+ raise Insika::ValidationError, "task_id is required" if task_id.empty?
20
+
21
+ task = @task_store.find(task_id) ||
22
+ (raise Insika::NotFoundError, "task '#{task_id}' not found")
23
+
24
+ # IN-PROCESS RESUME: a :paused task whose fiber is STILL alive (blocked
25
+ # on await) — do NOT re-dispatch (spawn would duplicate the fiber). Just post :resume;
26
+ # the fiber transitions paused->running and continues. (A live :waiting is resolved
27
+ # by ApproveAction, not here.)
28
+ if task.status == :paused && @executor.running?(task_id)
29
+ @executor.resume_live(task_id)
30
+ return { task_id: task_id }
31
+ end
32
+
33
+ # :queued: a turn that was in the SessionActor queue and never
34
+ # started at crash time (no checkpoint) — recovering = RUN from scratch, from the
35
+ # original Command. Profile comes from the agent in the Command itself.
36
+ if task.status == :queued
37
+ @executor.spawn_in_session(task, profile: profile_for(task), resume_from: nil)
38
+ return { task_id: task_id }
39
+ end
40
+
41
+ # RE-DISPATCH (crash-resume): no live fiber, re-executes from the checkpoint.
42
+ # resume requires a checkpoint; without one the task is unrecoverable (Recovery
43
+ # would already have marked it :failed during the sweep).
44
+ checkpoint = @checkpoint_store.latest(task_id) ||
45
+ (raise Insika::ValidationError,
46
+ "task '#{task_id}' has no checkpoint — unrecoverable")
47
+
48
+ check_eligibility!(task)
49
+
50
+ # profile comes from the checkpoint (agent_id): the agent may have been removed from
51
+ # the config between the crash and boot -> fail loudly and clearly.
52
+ profile = @profiles[checkpoint.agent_id] ||
53
+ (raise Insika::NotFoundError, "agent '#{checkpoint.agent_id}' not configured")
54
+
55
+ @executor.spawn_in_session(task, profile: profile, resume_from: checkpoint)
56
+ { task_id: task_id }
57
+ end
58
+
59
+ private
60
+
61
+ # Profile for re-running a :queued task: from the agent in the persisted Command
62
+ # (there is no checkpoint). NotFoundError if the agent left the config.
63
+ def profile_for(task)
64
+ payload = task.command["payload"] || task.command[:payload] || {}
65
+ agent = (payload["agent"] || payload[:agent]).to_s
66
+ @profiles[agent] || (raise Insika::NotFoundError, "agent '#{agent}' not configured")
67
+ end
68
+
69
+ # Eligibility matrix: paused/waiting always; running only when
70
+ # orphaned (no live fiber IN THIS process — single-node); :queued is handled
71
+ # earlier (re-run); terminal states are not resumable.
72
+ def check_eligibility!(task)
73
+ case task.status
74
+ when :paused, :waiting
75
+ nil
76
+ when :running
77
+ raise Insika::ValidationError, "task '#{task.id}' is running" if @executor.running?(task.id)
78
+ else # terminal states (:completed, :failed, :cancelled)
79
+ raise Insika::ValidationError,
80
+ "task '#{task.id}' with status '#{task.status}' is not resumable"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # WS1: revokes ONE token by id. The edge resolves tokens by hash, so the
6
+ # plaintext is not needed to kill a credential. Idempotent: revoking an
7
+ # already-revoked or unknown id -> { revoked: false }, never an error.
8
+ # -> { id:, revoked: bool }.
9
+ class RevokeToken
10
+ def initialize(token_store:, event_stream:)
11
+ @token_store = token_store
12
+ @event_stream = event_stream
13
+ end
14
+
15
+ def call(command)
16
+ raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
17
+
18
+ id = Insika::Coercion.presence(
19
+ command.payload[:token_id] || command.payload["token_id"]
20
+ )
21
+ raise Insika::ValidationError, "token_id is required" if id.nil?
22
+
23
+ revoked = @token_store.revoke(id)
24
+ emit(id, revoked)
25
+ { id: id, revoked: revoked }
26
+ end
27
+
28
+ private
29
+
30
+ def emit(token_id, revoked)
31
+ @event_stream.emit(Insika::Event.new(
32
+ type: :tenant_token_revoked,
33
+ data: { token_id: token_id, revoked: revoked },
34
+ meta: { at: Time.now.utc.iso8601 }
35
+ ))
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # WS1: rotates a tenant's credential — revokes every active token of the
6
+ # tenant and issues a fresh one, in ONE transaction (a crashed half-rotation
7
+ # must never leave the tenant with nothing valid). Only the tenant's own
8
+ # tokens are touched; the operator token and other tenants are untouched.
9
+ # -> { revoked: n, token: Issue.to_h }.
10
+ class RotateTenantToken
11
+ def initialize(token_store:, event_stream:)
12
+ @token_store = token_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
18
+
19
+ tenant_id = Insika::Coercion.presence(
20
+ command.payload[:tenant_id] || command.payload["tenant_id"]
21
+ )
22
+ raise Insika::ValidationError, "tenant_id is required" if tenant_id.nil?
23
+
24
+ label = command.payload[:label] || command.payload["label"] || "default"
25
+ result = @token_store.rotate(tenant_id: tenant_id, label: label)
26
+ issue = result[:issue]
27
+ emit(issue.id, tenant_id, result[:revoked])
28
+ { revoked: result[:revoked],
29
+ token: { token: issue.token, id: issue.id, tenant_id: tenant_id, label: label.to_s } }
30
+ end
31
+
32
+ private
33
+
34
+ def emit(token_id, tenant_id, revoked)
35
+ @event_stream.emit(Insika::Event.new(
36
+ type: :tenant_token_rotated,
37
+ data: { token_id: token_id, tenant_id: tenant_id, revoked: revoked },
38
+ meta: { at: Time.now.utc.iso8601 }
39
+ ))
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: reads a window of the agent's real traffic
8
+ # and records a ranked failure REPORT. Synchronous — it only scans durable stores
9
+ # (no provider call, no fiber), so it answers with the Run and does not create a
10
+ # Task. It is the ONLY way a refinement run starts: the CLI, the Studio button
11
+ # and any external cron all dispatch this one command (there is no
12
+ # scheduler in the engine).
13
+ #
14
+ # Payload:
15
+ # agent (required) agent id
16
+ # since ISO8601 — only turns from this instant on
17
+ # last_sessions Integer — the N most recent conversations instead
18
+ # full truthy — ignore the previous run and use the full window
19
+ # max_findings Integer cap on the report
20
+ # exclude_sessions [prefix] — session ids to drop (load tests, debug traffic)
21
+ #
22
+ # Window resolution (first that applies): explicit payload -> INCREMENTAL (since
23
+ # the previous run for this agent, unless `full`) -> the agent's configured
24
+ # `refinement.window` -> the collector's default.
25
+ #
26
+ # writes NOTHING to the agent, so it needs no opt-in: an absent
27
+ # `refinement` config reads as report-only. Only `propose`/`auto_apply`
28
+ # require the operator to enable them explicitly.
29
+ class RunRefinement
30
+ READ_ONLY_MODES = %w[report propose auto_apply].freeze
31
+
32
+ def initialize(profiles:, refinement_store:, collector:, event_stream:)
33
+ @profiles = ProfileSource.coerce(profiles)
34
+ @refinement_store = refinement_store
35
+ @collector = collector
36
+ @event_stream = event_stream
37
+ end
38
+
39
+ def call(command)
40
+ p = AgentPayload.symbolize(command.payload)
41
+ agent = AgentPayload.presence(p[:agent])
42
+ raise Insika::ValidationError, "agent is required" if agent.nil?
43
+
44
+ profile = @profiles[agent] ||
45
+ (raise Insika::NotFoundError, "agent '#{agent}' not configured")
46
+ config = refinement_config(profile)
47
+ validate_mode!(config)
48
+
49
+ window = resolve_window(agent, p, config)
50
+ run = @refinement_store.create(agent_id: agent, window: window)
51
+ emit(:refinement_started, agent: agent, run_id: run.id, window: window)
52
+
53
+ begin
54
+ report = @collector.collect(
55
+ agent_id: agent, max_findings: max_findings(p, config),
56
+ exclude_sessions: exclude_sessions(p, config), **collect_args(window)
57
+ )
58
+ done = @refinement_store.complete(run.id, findings: report.findings,
59
+ excluded: report.excluded)
60
+ emit(:refinement_report, agent: agent, run_id: run.id, status: done.status,
61
+ findings: done.findings_count,
62
+ sessions: report.sessions_seen, turns: report.turns_seen,
63
+ excluded: report.excluded)
64
+ done
65
+ rescue StandardError => e
66
+ @refinement_store.fail(run.id, error: e.message)
67
+ raise
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def refinement_config(profile)
74
+ raw = profile.respond_to?(:refinement) ? profile.refinement : nil
75
+ raw.is_a?(Hash) ? raw : {}
76
+ end
77
+
78
+ # An unknown mode is a config typo, and a typo that silently degrades to
79
+ # "report" would be the kind of quiet wrong the strict-config rule exists to
80
+ # prevent.
81
+ def validate_mode!(config)
82
+ mode = AgentPayload.presence(config["mode"]) || "report"
83
+ return if READ_ONLY_MODES.include?(mode)
84
+
85
+ raise Insika::ValidationError,
86
+ "unknown refinement mode: #{mode} (expected #{READ_ONLY_MODES.join(', ')})"
87
+ end
88
+
89
+ def resolve_window(agent, payload, config)
90
+ since = AgentPayload.presence(payload[:since])
91
+ return { "since" => since } if since
92
+ return { "last_sessions" => Integer(payload[:last_sessions]) } if payload[:last_sessions]
93
+
94
+ # `full` arrives as true (internal dispatch) or "1" (Studio form) — one
95
+ # reading of a boolean, the same one item #131 settled on.
96
+ unless Insika::EnvSchema.truthy?(payload[:full])
97
+ previous = @refinement_store.latest_for(agent)
98
+ return { "since" => previous.started_at } if previous&.started_at
99
+ end
100
+
101
+ configured = config.dig("window", "last_sessions")
102
+ configured ? { "last_sessions" => Integer(configured) } : {}
103
+ end
104
+
105
+ # Window (as recorded) -> collector kwargs. {} = the collector's own default.
106
+ def collect_args(window)
107
+ return { since: window["since"] } if window["since"]
108
+ return { last_sessions: window["last_sessions"] } if window["last_sessions"]
109
+
110
+ {}
111
+ end
112
+
113
+ def max_findings(payload, config)
114
+ raw = payload[:max_findings] || config["max_findings"]
115
+ raw ? Integer(raw) : Refinement::EvidenceCollector::DEFAULT_MAX_FINDINGS
116
+ end
117
+
118
+ # Session-id prefixes to drop (load tests, debug conversations). Configured per
119
+ # agent; a payload list overrides it for one run. Never defaulted — the report
120
+ # does not get to decide what counts as real traffic.
121
+ def exclude_sessions(payload, config)
122
+ Array(payload[:exclude_sessions] || config["exclude_sessions"]).map(&:to_s)
123
+ end
124
+
125
+ def emit(type, **data)
126
+ @event_stream.emit(Insika::Event.new(
127
+ type: type, data: data,
128
+ meta: { at: Time.now.utc.iso8601 }
129
+ ))
130
+ end
131
+ end
132
+ end
133
+ end
@@ -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
+ # 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 lands.
17
+ COALESCABLE_TRANSPORTS = %i[http:json].freeze
18
+
19
+ # `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"
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
+ # 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
+ # 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
+ # 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
+ # 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
+ # `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`: 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