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,89 @@
1
+ <%# Tools — drill-down (T2): [data tools + agents] master | agent tool matrix.
2
+ @sel_agent (from ?a=) drives the detail; data tools link to their editor page. %>
3
+ <div class="drill" data-controller="list-filter">
4
+ <aside class="drill-pane drill-master">
5
+ <div class="drill-pane-head">
6
+ <div class="title"><strong>Tools <span class="count"><%= @tools.size %></span></strong></div>
7
+ <a class="btn primary btn-sm" href="/studio/tools/def/new">+ data tool</a>
8
+ </div>
9
+ <div class="drill-search">
10
+ <input type="search" class="filter-input" placeholder="Filter agents…" aria-label="Filter agents"
11
+ data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
12
+ </div>
13
+ <nav class="drill-pane-body drill-list" aria-label="tools and agents">
14
+ <% unless @data_tool_names.empty? %>
15
+ <div class="drill-group-label">Data tools · HTTP, editable</div>
16
+ <% @data_tool_names.sort.each do |name| %>
17
+ <% dropped = @dropped_tool_names.include?(name) %>
18
+ <a class="drill-item drill-item-sm" href="/studio/tools/def/<%= Rack::Utils.escape(name) %>"
19
+ <%== %( title="its definition is invalid — no agent can call it until it is fixed") if dropped %>>
20
+ <div class="drill-item-name"><span class="txt"><%= name %></span><span class="pill plain">data</span><% if dropped %> <span class="pill locked">dropped</span><% end %></div>
21
+ </a>
22
+ <% end %>
23
+ <% end %>
24
+ <div class="drill-group-label">Agents · allow / deny</div>
25
+ <% @agents.each do |a| %>
26
+ <a class="drill-item<%== ' active' if @sel_agent && @sel_agent.id == a.id %>"
27
+ href="/studio/tools?a=<%= Rack::Utils.escape(a.id) %>"
28
+ data-list-filter-target="item" data-filter-text="<%= a.id %>">
29
+ <div class="drill-item-name"><span class="txt"><%= a.id %></span>
30
+ <span class="pill plain"><%= a.tools_allow.nil? ? "all" : "#{tools_on_count(a, @tools)}/#{@tools.size}" %></span>
31
+ </div>
32
+ </a>
33
+ <% end %>
34
+ <div class="empty" data-list-filter-target="empty" hidden>No agents match your filter.</div>
35
+ </nav>
36
+ </aside>
37
+
38
+ <section class="drill-pane drill-detail">
39
+ <% if @sel_agent.nil? %>
40
+ <div class="drill-empty">
41
+ <div>
42
+ <h2>Tools</h2>
43
+ <p>Pick an agent to set which tools it can call — or edit a data tool on the left. <strong>all</strong> = open allowlist (every tool flows through); a <span class="pill locked">locked</span> tool is on the denylist (deny always wins).</p>
44
+ </div>
45
+ </div>
46
+ <% else %>
47
+ <% a = @sel_agent %>
48
+ <div class="drill-pane-head">
49
+ <div class="title">
50
+ <nav class="crumbs"><a href="/studio/tools">tools</a> / <span><%= a.id %></span></nav>
51
+ <strong class="mono"><%= a.id %></strong>
52
+ </div>
53
+ <button type="submit" form="tools-form" class="btn primary" data-turbo-submits-with="Saving…">Save tools</button>
54
+ </div>
55
+ <div class="drill-pane-body" data-controller="toggle-counter">
56
+ <div class="matrix-topline">
57
+ <span class="count-badge mono" data-toggle-counter-target="count"><%= a.tools_allow.nil? ? "all" : "#{tools_on_count(a, @tools)}/#{@tools.size} on" %></span>
58
+ <label class="switch" title="open allowlist — every tool flows through">
59
+ <input type="checkbox" name="all_tools" value="1" form="tools-form"<%== " checked" if a.tools_allow.nil? %>
60
+ data-toggle-counter-target="all" data-action="change->toggle-counter#update">
61
+ <span class="switch-track" aria-hidden="true"></span>
62
+ <span class="switch-label">all tools</span>
63
+ </label>
64
+ </div>
65
+ <p class="muted">Checked tools are allowed next turn. A <span class="pill locked">locked</span> tool is on the denylist — deny always wins, so it can't be granted here.</p>
66
+ <form id="tools-form" method="post" action="/studio/tools/<%= Rack::Utils.escape(a.id) %>">
67
+ <%== csrf_tag %>
68
+ <% if @tools.empty? %>
69
+ <p class="muted">No tools registered in the runtime.</p>
70
+ <% else %>
71
+ <div class="check-grid tool-grid<%== " is-open" if a.tools_allow.nil? %>" data-toggle-counter-target="grid">
72
+ <% @tools.each do |t| %>
73
+ <% denied = tool_denied_for?(a, t.name) %>
74
+ <label class="check tool-check<%== " locked" if denied %>" title="<%= denied ? "denied — deny wins" : t.description %>">
75
+ <input type="checkbox" name="tools[]" value="<%= t.name %>"
76
+ <%== " checked" if tool_allowed_for?(a, t.name) && !denied %>
77
+ <%== " disabled" if denied %>
78
+ data-toggle-counter-target="tool" data-locked="<%= denied %>"
79
+ data-action="change->toggle-counter#update">
80
+ <span class="mono"><%= t.name %></span><% if @data_tool_names.include?(t.name) %> <span class="pill plain">data</span><% end %><% if denied %> <span class="pill locked">locked</span><% end %>
81
+ </label>
82
+ <% end %>
83
+ </div>
84
+ <% end %>
85
+ </form>
86
+ </div>
87
+ <% end %>
88
+ </section>
89
+ </div>
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Definition-time integrity of the subagent delegation graph (
5
+ # A pure function over `{id => [child_ids]}`: detects CYCLES and
6
+ # computes the max delegation DEPTH, raising a typed SubagentError so the
7
+ # authoring Command (CreateAgent/UpdateAgent) fails cleanly and boot refuses a
8
+ # bad static set. Cycle + bounded depth are exactly Flue's definition-time
9
+ # guarantee (`DelegationDepthExceededError` + anti-circular) — with the graph
10
+ # acyclic and depth <= cap, the runtime is provably bounded (the guard in
11
+ # Executor#run_subagent is only belt-and-suspenders for a graph that changed
12
+ # mid-run).
13
+ #
14
+ # UNKNOWN child refs are treated as LEAVES (no outgoing edges), NOT an error:
15
+ # dynamic authoring must not break by creation order — a not-yet-created child
16
+ # surfaces as a clean "not found" at runtime (run_subagent), not a boot failure.
17
+ module SubagentGraph
18
+ # Longest delegation chain allowed (root counts as depth 0; each spawn +1).
19
+ # Override with INSIKA_SUBAGENT_DEPTH_CAP.
20
+ DEFAULT_DEPTH_CAP = 5
21
+
22
+ # Max children a single `spawn_subagents` fan-out may run — also the concurrency
23
+ # bound (N concurrent LLM calls hit the provider rate limit + the per-agent token
24
+ # ceiling of). Override with INSIKA_SUBAGENT_FANOUT_CAP.
25
+ DEFAULT_FANOUT_CAP = 8
26
+
27
+ module_function
28
+
29
+ def depth_cap
30
+ int_env("INSIKA_SUBAGENT_DEPTH_CAP", DEFAULT_DEPTH_CAP)
31
+ end
32
+
33
+ def fan_out_cap
34
+ int_env("INSIKA_SUBAGENT_FANOUT_CAP", DEFAULT_FANOUT_CAP)
35
+ end
36
+
37
+ def int_env(name, default)
38
+ raw = Insika::EnvSchema.read(name)
39
+ raw && !raw.strip.empty? ? Integer(raw) : default
40
+ end
41
+
42
+ # Validates the whole set. `profiles` is anything enumerable of profiles
43
+ # responding to #id and #subagents (an Array or a ProfileSource#all result),
44
+ # OR a plain `{id => [child_ids]}` Hash. Raises on the FIRST violation.
45
+ def validate!(profiles, cap: depth_cap)
46
+ map = to_map(profiles)
47
+ map.each_key { |root| check_from(root, map, cap) }
48
+ map
49
+ end
50
+
51
+ # Builds the {id => [child_ids]} adjacency map. Absent/nil subagents => [].
52
+ def to_map(profiles)
53
+ return normalize(profiles) if profiles.is_a?(Hash)
54
+
55
+ each_profile(profiles).each_with_object({}) do |p, acc|
56
+ acc[p.id.to_s] = Array(p.subagents).map(&:to_s)
57
+ end
58
+ end
59
+
60
+ # DFS from `root` tracking the recursion stack (cycle) and the longest path
61
+ # (depth). A ref to an id absent from `map` is a leaf.
62
+ def check_from(root, map, cap)
63
+ walk(root, map, cap, [], {})
64
+ end
65
+
66
+ # Returns the max depth of the subtree rooted at `node`. `stack` is the
67
+ # current path (cycle detection); `memo` caches finished subtrees.
68
+ def walk(node, map, cap, stack, memo)
69
+ raise SubagentCycleError.new(cycle: stack + [node]) if stack.include?(node)
70
+ return memo[node] if memo.key?(node)
71
+
72
+ children = map[node] || [] # unknown ref => leaf
73
+ depth = if children.empty?
74
+ 0
75
+ else
76
+ 1 + children.map { |c| walk(c, map, cap, stack + [node], memo) }.max
77
+ end
78
+ raise SubagentDepthExceeded.new(depth: depth, cap: cap) if depth > cap
79
+
80
+ memo[node] = depth
81
+ end
82
+
83
+ def normalize(hash)
84
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = Array(v).map(&:to_s) }
85
+ end
86
+
87
+ # Duck-typed enumeration: an Array of profiles, or a ProfileSource exposing
88
+ # #all. Anything else Enumerable is iterated as-is.
89
+ def each_profile(profiles)
90
+ return profiles if profiles.is_a?(Array)
91
+ return profiles.all if profiles.respond_to?(:all)
92
+
93
+ Array(profiles)
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # GLOBAL system files. These are prompts/rules
7
+ # that apply to ALL agents in the deploy — the "house" above each BIA's
8
+ # individual identity. Unlike the AgentFileStore (per agent), here there is no
9
+ # tenant: one record per file in the ConfigStore (scope "system_files").
10
+ #
11
+ # Context::Providers::Prompt reads these files and injects them BEFORE the
12
+ # per-agent identity, for every turn. With no system files (empty store),
13
+ # the prompt is byte-for-byte the one from before (parity preserved) — the global
14
+ # injection only exists when the operator authors something here.
15
+ #
16
+ # Record per file:
17
+ # { "content" => str, "updated_at" => iso8601,
18
+ # "history" => [ { "content" => str, "at" => iso8601 }, ... ] }
19
+ #
20
+ # A write versions (same contract as AgentFileStore): the previous content
21
+ # goes into `history` (most recent first), capped at HISTORY_MAX; restoring
22
+ # is a new write (linear history, no destructive "time travel").
23
+ class SystemFileStore
24
+ SCOPE = "system_files"
25
+ HISTORY_MAX = 20
26
+
27
+ def initialize(config_store:)
28
+ @cs = config_store
29
+ end
30
+
31
+ # -> String | nil (current content).
32
+ def read(filename)
33
+ entry(filename.to_s)&.fetch("content", nil)
34
+ end
35
+
36
+ # -> [String] file names, lexicographic order.
37
+ def list
38
+ @cs.keys(SCOPE).sort
39
+ end
40
+
41
+ # Writes (upsert). create_only: refuses to overwrite. Versions the previous one into
42
+ # history. -> Hash (the stored entry).
43
+ def write(filename, content, create_only: false)
44
+ name = filename.to_s
45
+ raise Insika::ValidationError, "file is required" if name.empty?
46
+
47
+ current = entry(name)
48
+ if create_only && current
49
+ raise Insika::ValidationError, "system file '#{name}' already exists"
50
+ end
51
+
52
+ built = build_entry(content.to_s, current)
53
+ @cs.put(SCOPE, name, built)
54
+ built
55
+ end
56
+
57
+ # -> bool (did it exist?).
58
+ def delete(filename)
59
+ @cs.delete(SCOPE, filename.to_s)
60
+ end
61
+
62
+ # -> [ { "content" =>, "at" => } ] older versions, most recent first.
63
+ def versions(filename)
64
+ entry(filename.to_s)&.fetch("history", []) || []
65
+ end
66
+
67
+ # Restores version `index` from history as the current content (a new write).
68
+ # -> Hash (entry) | raises if index invalid / file nonexistent.
69
+ def restore(filename, index)
70
+ name = filename.to_s
71
+ current = entry(name)
72
+ raise Insika::NotFoundError, "system file '#{name}' not found" unless current
73
+
74
+ hist = current.fetch("history", [])
75
+ i = Integer(index)
76
+ raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
77
+
78
+ write(name, hist[i]["content"])
79
+ end
80
+
81
+ private
82
+
83
+ def entry(name)
84
+ @cs.get(SCOPE, name)
85
+ end
86
+
87
+ def build_entry(content, current)
88
+ history = current ? current.fetch("history", []) : []
89
+ if current
90
+ history = [{ "content" => current["content"], "at" => current["updated_at"] }] + history
91
+ history = history.first(HISTORY_MAX)
92
+ end
93
+ { "content" => content, "updated_at" => Time.now.utc.iso8601, "history" => history }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+
6
+ module Insika
7
+ # Actor model: one Async fiber per Task + a mailbox. The message enum is
8
+ # `cancel`/`user_message`/`approval`/`pause`/`resume`/`timeout`/`heartbeat`, with
9
+ # `await` as the cooperative SUSPENSION primitive. Cancellation/suspension only
10
+ # at stage boundaries — never in the middle of an operation.
11
+ class TaskActor
12
+ # `user_message` is posted by `Executor#steer_into_running` and
13
+ # consumed by `SteerInjector` at a tool-batch boundary. `pause`/`resume` (operator),
14
+ # `approval` (human-in-the-loop), `timeout`/`heartbeat`
15
+ # (watchdog/liveness, observation).
16
+ MESSAGES = %i[cancel user_message approval pause resume timeout heartbeat].freeze
17
+
18
+ attr_reader :task_id, :pending_user_messages, :heartbeats
19
+ # How many messages this run was ASKED to absorb, ever — including the ones
20
+ # already injected and cleared. It is what bounds steering (`steer_max_messages`),
21
+ # so it counts posts and never decreases.
22
+ attr_reader :user_messages_posted
23
+
24
+ def initialize(task_id:, parent: Async::Task.current)
25
+ @task_id = task_id
26
+ @parent = parent
27
+ @mailbox = Async::Queue.new
28
+ @pending_user_messages = []
29
+ @pause_requested = false
30
+ @heartbeats = 0
31
+ @user_messages_posted = 0
32
+ end
33
+
34
+ # Non-blocking. A message outside the enum is a caller bug.
35
+ def post(message, data = nil)
36
+ raise ArgumentError, "unknown message: #{message}" unless MESSAGES.include?(message)
37
+
38
+ @user_messages_posted += 1 if message == :user_message
39
+ @mailbox.enqueue([message, data])
40
+ nil
41
+ end
42
+
43
+ # Runs the block on an Async fiber CHILD of the parent. Returns the Task.
44
+ def run(&turn_block)
45
+ @async_task = @parent.async { turn_block.call(self) }
46
+ end
47
+
48
+ # Drains the mailbox WITHOUT blocking (boundaries). `:cancel` raises (the top
49
+ # of the fiber maps to :cancelled). `:pause` arms the suspension (the Executor
50
+ # checks `pause_requested?`). Resolutions (`:resume`/`:approval`/`:timeout`)
51
+ # that arrive here WITH no pending suspension are DISCARDED (idempotent,
52
+ # no-op).
53
+ def drain!
54
+ until @mailbox.empty?
55
+ route_boundary(*@mailbox.dequeue)
56
+ end
57
+ nil
58
+ end
59
+
60
+ # Did the operator request a pause? (consumed by the Executor; `await` clears
61
+ # the flag).
62
+ def pause_requested? = @pause_requested
63
+
64
+ # takes the steered messages and clears the buffer, WITHOUT
65
+ # observing anything else in the mailbox: whatever is not a `:user_message` is put
66
+ # back, in the order it arrived.
67
+ #
68
+ # Why not `drain!`: this runs INSIDE RubyLLM's tool loop (a `after_message`
69
+ # callback), and `drain!` raises on `:cancel`. Cancellation is only ever observed
70
+ # at the Executor's own stage boundaries — that is what keeps a tool batch one
71
+ # unit of work (and for the same rule under `turn_timeout`).
72
+ # Injecting a message must not quietly become a new place a turn can die.
73
+ def take_user_messages!
74
+ @mailbox.size.times do
75
+ message, data = @mailbox.dequeue
76
+ message == :user_message ? @pending_user_messages << data : @mailbox.enqueue([message, data])
77
+ end
78
+ taken = @pending_user_messages.dup
79
+ @pending_user_messages.clear
80
+ taken
81
+ end
82
+
83
+ # BLOCKS the turn's fiber until a RESOLUTION (yields the reactor — no spin).
84
+ # Used by the Executor in :paused (waits for :resume) and by the ToolEnvelope
85
+ # in :waiting (waits for :approval). Returns [:resume, nil] or [:approval,
86
+ # data]. `:cancel` -> CancelledError; `:timeout` -> TimeoutError. A legitimate
87
+ # resolution only arrives WITH the fiber already blocked here (the operator
88
+ # only resumes/approves what is suspended), so it is consumed by this
89
+ # `dequeue` — there is no race requiring a buffer. Non-resolution messages
90
+ # received during the wait are ABSORBED without changing the suspension state
91
+ # (a redundant :pause does not re-arm the pause).
92
+ def await(reason:)
93
+ @pause_requested = false # the pause/wait is being handled now
94
+ loop do
95
+ message, data = @mailbox.dequeue
96
+ case message
97
+ when :cancel then raise CancelledError, "task #{@task_id} cancelled"
98
+ when :timeout then raise Insika::TimeoutError.new("wait (#{reason}) exceeded", stage: data || reason)
99
+ when :resume, :approval then return [message, data]
100
+ when :heartbeat then @heartbeats += 1
101
+ when :user_message then @pending_user_messages << data
102
+ # :pause during the wait: already suspended, ignore (does not re-arm pause_requested)
103
+ end
104
+ end
105
+ end
106
+
107
+ # specs/boot await the fiber's completion.
108
+ def wait = @async_task&.wait
109
+
110
+ private
111
+
112
+ # Routing of boundary messages (non-blocking). Orphan resolutions
113
+ # (`:resume`/`:approval`/`:timeout` with no pending suspension) are DISCARDED —
114
+ # NEVER buffered: a stored resolution would wrongly resolve a FUTURE `await`
115
+ # (auto-resume/auto-approve/auto-timeout of a suspension the operator did not
116
+ # resolve). Legitimate resolutions arrive with the fiber already in `await`
117
+ # (consumed there), so discarding here is safe and idempotent.
118
+ def route_boundary(message, data)
119
+ case message
120
+ when :cancel then raise CancelledError, "task #{@task_id} cancelled"
121
+ when :pause then @pause_requested = true
122
+ when :user_message then @pending_user_messages << data
123
+ when :heartbeat then @heartbeats += 1
124
+ when :resume, :approval, :timeout then nil # orphan: discard (see comment)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Domain store for tasks. Persists Tasks over an
8
+ # injected Insika::Store, with the STATE MACHINE validated here: the store is
9
+ # the only place status is written, so the
10
+ # invariants live where the writes live. An invalid transition is a bug and raises
11
+ # ArgumentError loud and early — this is how logical races are
12
+ # detected without a lock.
13
+ #
14
+ # Each Execution is ONE attempt; retry/resume opens a new entry, never
15
+ # overwrites.
16
+ class TaskStore
17
+ include Coercion
18
+
19
+ SCOPE = "tasks"
20
+ KEY_PREFIX = "task:"
21
+
22
+ STATUSES = %i[queued running waiting paused completed failed cancelled].freeze
23
+
24
+ # Valid transitions — anything outside this is a bug -> ArgumentError.
25
+ TRANSITIONS = {
26
+ # queued -> failed: a turn queued in the SessionActor may fail
27
+ # at STARTUP (spawn error before the fiber) without ever running.
28
+ queued: %i[running cancelled failed],
29
+ running: %i[waiting paused completed failed cancelled],
30
+ waiting: %i[running cancelled failed],
31
+ paused: %i[running cancelled],
32
+ completed: [], failed: [], cancelled: [] # terminal
33
+ }.freeze
34
+
35
+ Task = Data.define(:id, :status, :command, :session_id, :executions,
36
+ :mailbox_state, :created_at, :updated_at)
37
+ Execution = Data.define(:attempt, :started_at, :finished_at, :outcome, :error)
38
+
39
+ def initialize(store:)
40
+ @store = store
41
+ end
42
+
43
+ # -> Task (status :queued). command: Hash ({type:, payload:, meta:}) or
44
+ # any object that responds to to_h (e.g. Insika::Command).
45
+ # ArgumentError if the id already exists. `at` (ISO8601) is injectable for
46
+ # deterministic tests — the timestamp has SECOND precision, so two tasks created
47
+ # in the same second are indistinguishable by time to any reader ordering by it
48
+ # (same rationale as MemoryStore#add_note).
49
+ def create(command:, session_id: nil, id: SecureRandom.uuid, at: nil)
50
+ key = key_for(id)
51
+ raise ArgumentError, "task already exists: #{id}" unless @store.get(SCOPE, key).nil?
52
+
53
+ now = at || timestamp
54
+ record = {
55
+ "id" => id.to_s,
56
+ "status" => "queued",
57
+ "command" => deep_stringify(command.respond_to?(:to_h) ? command.to_h : command),
58
+ "session_id" => session_id&.to_s,
59
+ "executions" => [],
60
+ "mailbox_state" => { "pending" => [] },
61
+ "created_at" => now,
62
+ "updated_at" => now
63
+ }
64
+ @store.set(SCOPE, key, record)
65
+ to_task(record)
66
+ end
67
+
68
+ # -> Task | nil
69
+ def find(id)
70
+ record = @store.get(SCOPE, key_for(id))
71
+ record && to_task(record)
72
+ end
73
+
74
+ # -> Task; validates the state machine. NotFoundError if absent,
75
+ # ArgumentError for a status outside the enum or an invalid transition.
76
+ # If `error:` is provided AND there is an open Execution, it closes it in the same write
77
+ # (Recovery path).
78
+ #
79
+ # The read-check-write rides Store#transaction because this is also the
80
+ # dispatch CLAIM: two workers racing queued -> running serialize on the
81
+ # backend's lock, the loser re-reads :running and gets the loud
82
+ # ArgumentError instead of a second silent owner.
83
+ def transition(id, to:, error: nil)
84
+ target = to.to_sym
85
+ raise ArgumentError, "invalid status: #{to}" unless STATUSES.include?(target)
86
+
87
+ @store.transaction do
88
+ record = fetch!(id)
89
+ from = record["status"].to_sym
90
+ unless TRANSITIONS.fetch(from).include?(target)
91
+ raise ArgumentError, "invalid transition: #{from} -> #{target}"
92
+ end
93
+
94
+ close_open_execution(record, outcome: target.to_s, error: error) if error
95
+ record["status"] = target.to_s
96
+ record["updated_at"] = timestamp
97
+ @store.set(SCOPE, key_for(id), record)
98
+ to_task(record)
99
+ end
100
+ end
101
+
102
+ # -> Task; opens an Execution (attempt N+1). ArgumentError if one is already
103
+ # open (a double attempt is a bug — one owner per task). Append-only.
104
+ def begin_execution(id)
105
+ record = fetch!(id)
106
+ raise ArgumentError, "an open Execution already exists on task #{id}" if open_execution(record)
107
+
108
+ record["executions"] += [{
109
+ "attempt" => record["executions"].size + 1,
110
+ "started_at" => timestamp,
111
+ "finished_at" => nil,
112
+ "outcome" => nil,
113
+ "error" => nil
114
+ }]
115
+ record["updated_at"] = timestamp
116
+ @store.set(SCOPE, key_for(id), record)
117
+ to_task(record)
118
+ end
119
+
120
+ # -> Task; closes the current Execution. ArgumentError if none is open.
121
+ # Does NOT touch status (that is transition's job).
122
+ def finish_execution(id, outcome:)
123
+ record = fetch!(id)
124
+ open = open_execution(record)
125
+ raise ArgumentError, "no open Execution on task #{id}" if open.nil?
126
+
127
+ open["finished_at"] = timestamp
128
+ open["outcome"] = outcome.to_s
129
+ record["updated_at"] = timestamp
130
+ @store.set(SCOPE, key_for(id), record)
131
+ to_task(record)
132
+ end
133
+
134
+ # (`collect`): appends a fragment to a task's message while it is
135
+ # still waiting at the door. -> Task.
136
+ #
137
+ # ONLY on :queued, and that guard is the whole safety of the feature: once a
138
+ # turn is :running its input has been read into the Chat, seeded into the
139
+ # context and possibly sent to the provider — rewriting it there would mean the
140
+ # transcript disagrees with what the model actually saw. ArgumentError on any
141
+ # other status, so a lost race fails loudly instead of corrupting a turn.
142
+ def append_message(id, text, separator: "\n")
143
+ fragment = presence(text)
144
+ return to_task(fetch!(id)) if fragment.nil?
145
+
146
+ record = fetch!(id)
147
+ unless record["status"] == "queued"
148
+ raise ArgumentError, "task #{id} is #{record['status']}, not queued: its message is already in flight"
149
+ end
150
+
151
+ payload = (record["command"]["payload"] ||= {})
152
+ current = presence(payload["message"])
153
+ payload["message"] = current ? "#{current}#{separator}#{fragment}" : fragment
154
+ record["updated_at"] = timestamp
155
+ @store.set(SCOPE, key_for(id), record)
156
+ to_task(record)
157
+ end
158
+
159
+ # -> [Task] with one of the given statuses. O(n) scan at boot;
160
+ # acceptable (one node, local SQLite).
161
+ def with_status(*statuses)
162
+ wanted = statuses.flatten
163
+ @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
164
+ record = @store.get(SCOPE, key)
165
+ next if record.nil?
166
+
167
+ task = to_task(record)
168
+ task if wanted.include?(task.status)
169
+ end
170
+ end
171
+
172
+ # Interrupted (crashed mid-turn): have a checkpoint -> resume.
173
+ def running_or_interrupted = with_status(:running, :waiting, :paused)
174
+
175
+ # Queued but never started (turn in the SessionActor queue at the
176
+ # crash) — no checkpoint; recovering = RUN from scratch (Recovery/ResumeTask).
177
+ def queued = with_status(:queued)
178
+
179
+ # -> enumerates ids without the "task:" prefix; without a block returns an Enumerator.
180
+ def each_id
181
+ return enum_for(:each_id) unless block_given?
182
+
183
+ @store.list(SCOPE, KEY_PREFIX).each do |key|
184
+ yield key.delete_prefix(KEY_PREFIX)
185
+ end
186
+ end
187
+
188
+ private
189
+
190
+ def key_for(id)
191
+ "#{KEY_PREFIX}#{id}"
192
+ end
193
+
194
+ # NotFoundError if absent (nonexistent task -> 404). Backend StoreError
195
+ # propagates without re-wrapping.
196
+ def fetch!(id)
197
+ record = @store.get(SCOPE, key_for(id))
198
+ raise Insika::NotFoundError, "task not found: #{id}" if record.nil?
199
+
200
+ record
201
+ end
202
+
203
+ # The open Execution is the last one with finished_at nil (one owner per task, so
204
+ # there is at most one). Returns the raw Hash (mutable in-place for the RMW).
205
+ def open_execution(record)
206
+ last = record["executions"].last
207
+ last if last && last["finished_at"].nil?
208
+ end
209
+
210
+ def close_open_execution(record, outcome:, error:)
211
+ open = open_execution(record)
212
+ return if open.nil? # no open attempt: nowhere to record
213
+
214
+ open["finished_at"] = timestamp
215
+ open["outcome"] = outcome
216
+ open["error"] = deep_stringify(error)
217
+ end
218
+
219
+ # Materializes Task from the raw Hash (type normalization at the edge):
220
+ # `status` comes back as a Symbol (domain enum, compared against
221
+ # STATUSES); `command`/`mailbox_state`/`error` stay as Hashes with string keys
222
+ # (they are data, not enums).
223
+ def to_task(record)
224
+ Task.new(
225
+ id: record["id"],
226
+ status: record["status"].to_sym,
227
+ command: record["command"],
228
+ session_id: record["session_id"],
229
+ executions: record["executions"].map { |e| to_execution(e) },
230
+ mailbox_state: record["mailbox_state"],
231
+ created_at: record["created_at"],
232
+ updated_at: record["updated_at"]
233
+ )
234
+ end
235
+
236
+ def to_execution(hash)
237
+ Execution.new(
238
+ attempt: hash["attempt"],
239
+ started_at: hash["started_at"],
240
+ finished_at: hash["finished_at"],
241
+ outcome: hash["outcome"],
242
+ error: hash["error"]
243
+ )
244
+ end
245
+
246
+ def timestamp
247
+ Time.now.utc.iso8601
248
+ end
249
+ end
250
+ end