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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Source of AgentProfiles. Profiles used to be
5
+ # a FROZEN Hash injected into the Executor and the turn Commands — static,
6
+ # defined in Ruby at wiring time. For the Studio to create/edit agents at
7
+ # runtime, the source needs to be MUTABLE and reloadable, without changing the
8
+ # consumption contract.
9
+ #
10
+ # The consumption contract is minimal and duck-typed: `source[id] -> AgentProfile|nil`
11
+ # (like a Hash). That's why the refactor in the Executor/Commands is just
12
+ # normalizing the input (legacy Hash -> StaticProfileSource); the bodies that do
13
+ # `@profiles[agent]` stay identical. `all`/`ids` are for the Studio to list.
14
+ #
15
+ # `nil` from `[]`/`fetch` = agent not configured (the Commands raise
16
+ # NotFoundError) — NEVER raises (unlike Hash#fetch).
17
+ module ProfileSource
18
+ # Hash-compat sugar: `source[id]`.
19
+ def [](id) = fetch(id)
20
+
21
+ # Normalizes the consumers' input: a legacy Hash becomes a StaticProfileSource;
22
+ # a ProfileSource passes straight through. A single place for the compat seam.
23
+ def self.coerce(profiles)
24
+ return profiles if profiles.is_a?(ProfileSource)
25
+
26
+ StaticProfileSource.new(profiles || {})
27
+ end
28
+
29
+ # subclasses implement: fetch(id) -> AgentProfile|nil, all -> [AgentProfile], ids -> [String]
30
+ end
31
+
32
+ # Static source (parity): wraps the usual {id => AgentProfile} Hash.
33
+ # Behavior IDENTICAL to the frozen Hash — zero regression.
34
+ class StaticProfileSource
35
+ include ProfileSource
36
+
37
+ def initialize(profiles = {})
38
+ @profiles = profiles
39
+ end
40
+
41
+ def fetch(id) = @profiles[id]
42
+ def all = @profiles.values
43
+ def ids = @profiles.keys
44
+ end
45
+
46
+ # Persisted source (Studio): reads/writes profiles in the ConfigStore (scope "agents").
47
+ # Each `fetch` reads FRESH from the store — an edit via the Studio takes effect on
48
+ # the next dispatch, without a restart. An in-flight turn keeps the profile it
49
+ # captured (the Commands resolve at the start of #call), so the turn semantics are
50
+ # preserved.
51
+ class StoredProfileSource
52
+ include ProfileSource
53
+ include Coercion
54
+
55
+ SCOPE = "agents"
56
+
57
+ def initialize(config_store:)
58
+ @cs = config_store
59
+ end
60
+
61
+ def fetch(id)
62
+ record = @cs.get(SCOPE, id.to_s)
63
+ record && deserialize(record)
64
+ end
65
+
66
+ def all = @cs.all(SCOPE).map { |r| deserialize(r) }
67
+ def ids = @cs.keys(SCOPE)
68
+
69
+ # Write (used by the :create_agent/:update_agent Commands).
70
+ def put(profile)
71
+ @cs.put(SCOPE, profile.id, profile.to_h)
72
+ profile
73
+ end
74
+
75
+ def delete(id) = @cs.delete(SCOPE, id.to_s)
76
+
77
+ private
78
+
79
+ # Rebuilds the AgentProfile from the record (the JSON round-trip turns symbols
80
+ # into strings). Re-symbolizes the fields the runtime consumes as symbols:
81
+ # provider, policies (names in the PolicyRegistry) and the limits keys (
82
+ # DEFAULT_LIMITS uses symbols and the merge would break with string keys).
83
+ def deserialize(record)
84
+ h = symbolize_top(record)
85
+ AgentProfile.build(
86
+ id: h[:id], model: h[:model],
87
+ provider: presence(h[:provider])&.to_sym,
88
+ base_prompt: h[:base_prompt].to_s,
89
+ prompt_files: h[:prompt_files] || [],
90
+ tools_allow: h[:tools_allow], tools_deny: h[:tools_deny] || [],
91
+ tools_allow_groups: h[:tools_allow_groups],
92
+ skills: h[:skills],
93
+ skills_eager: h[:skills_eager],
94
+ context_providers: h[:context_providers],
95
+ workflows_allow: h[:workflows_allow],
96
+ policies: Array(h[:policies]).map(&:to_sym),
97
+ prompt_refs: h[:prompt_refs] || [],
98
+ limits: symbolize_limits(h[:limits]),
99
+ approvals_required: h[:approvals_required],
100
+ capabilities: h[:capabilities],
101
+ # subagents: allowlist of child ids; build re-normalizes to
102
+ # [String]. nil round-trips as nil (opt-in: NONE).
103
+ subagents: h[:subagents],
104
+ tools_deferred: h[:tools_deferred],
105
+ memory: h[:memory],
106
+ prompt_caching: h[:prompt_caching],
107
+ tool_output_compression: h[:tool_output_compression],
108
+ # params/model_policy: the resolver tolerates string keys from
109
+ # the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
110
+ # re-symbolization needed here.
111
+ params: h[:params] || {},
112
+ model_policy: h[:model_policy],
113
+ budget: h[:budget],
114
+ reliability: h[:reliability],
115
+ alerts: h[:alerts],
116
+ stuck_signal: h[:stuck_signal],
117
+ # guardrails: a plain Hash; Safety::Config tolerates the JSON
118
+ # round-trip (string keys/values), so no re-symbolization here.
119
+ guardrails: h[:guardrails],
120
+ # sandbox: a plain config Hash; Sandbox.build tolerates the JSON
121
+ # round-trip (string keys), so no re-symbolization here. nil = absent.
122
+ sandbox: h[:sandbox],
123
+ # refinement: a plain config Hash read with string keys by the
124
+ # RunRefinement handler; nil round-trips as nil (= report-only).
125
+ refinement: h[:refinement],
126
+ # capabilities_declared: flat [String]; build re-normalizes.
127
+ capabilities_declared: h[:capabilities_declared],
128
+ # edge_stream: which internal channels may cross to the customer. {} = neither.
129
+ edge_stream: h[:edge_stream],
130
+ metadata: h[:metadata] || {}
131
+ )
132
+ end
133
+
134
+ def symbolize_top(record) = record.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
135
+
136
+ # limits keys -> symbol; numeric values preserved by JSON.
137
+ def symbolize_limits(limits)
138
+ return {} if limits.nil?
139
+
140
+ limits.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Insika
6
+ # Catalog of prompts: NON-executable content
7
+ # (it is a Catalog, not a Registry). Mirrors the SkillCatalog: each prompt is a directory
8
+ # with a PROMPT.md (YAML frontmatter name/description + markdown body).
9
+ # Source for the Prompt provider when the profile uses prompt_refs.
10
+ class PromptCatalog
11
+ Prompt = Data.define(:name, :description, :path, :body)
12
+
13
+ # roots ordered by PRECEDENCE (highest first) — same name in more than
14
+ # one root: the first wins (identical to SkillCatalog).
15
+ def initialize(roots)
16
+ @roots = Array(roots)
17
+ @prompts = load_all
18
+ end
19
+
20
+ def all = @prompts.values
21
+
22
+ # -> Prompt | nil (the Prompt provider converts nil into a ContextError; it is not
23
+ # the catalog's job to raise).
24
+ def find(name) = @prompts[name.to_s]
25
+
26
+ # Rescan + atomic index swap: parity with the SkillCatalog
27
+ # for reload without a restart when the on-disk seed changes.
28
+ def reload
29
+ @prompts = load_all
30
+ self
31
+ end
32
+
33
+ private
34
+
35
+ def load_all
36
+ found = {}
37
+ @roots.each do |root|
38
+ Dir.glob(File.join(root, "**", "PROMPT.md")).sort.each do |file|
39
+ prompt = parse(file)
40
+ next unless prompt
41
+
42
+ found[prompt.name] ||= prompt # precedence: first root wins
43
+ end
44
+ end
45
+ found
46
+ end
47
+
48
+ def parse(file)
49
+ raw = File.read(file, encoding: "UTF-8")
50
+ match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
51
+ return nil unless match
52
+
53
+ meta = YAML.safe_load(match[1]) || {}
54
+ name = meta["name"]
55
+ return nil unless name
56
+
57
+ Prompt.new(name: name.to_s, description: meta["description"].to_s,
58
+ path: file, body: match[2].strip)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Classifies provider/transport failures by ACTION (B9). Four kinds —
5
+ # the hermes/openclaw structural rule: NON-retryable checked first, so an
6
+ # error we do not recognize defaults to :fatal (a retry would hammer a
7
+ # poisoned credential; a fatal is retried only after the operator fixes the
8
+ # cause):
9
+ #
10
+ # :fatal 401/402/403/400 (auth, billing, permanent quota,
11
+ # bad request, context too long) — retrying does not help.
12
+ # :retryable 5xx/529/socket/timeout — the same call may succeed
13
+ # moments later.
14
+ # :rate_limited_short a 429 that says "back off briefly" (RPM-scale).
15
+ # :rate_limited_long a 429 with a long retry-after — quota-scale.
16
+ #
17
+ # The classification is STRING-based (class names, no constant references):
18
+ # the core loads without ruby_llm, and the smoke-shim's fake RubyLLM is a
19
+ # drop-in. `retry_after` is read from the provider's own Retry-After header
20
+ # when the error carries a response, else a per-kind default.
21
+ class ProviderErrorClassifier
22
+ Classification = Data.define(:kind, :retryable, :retry_after) do
23
+ # the additive envelope fields — compacted so a retry_after-less fatal
24
+ # never invents one.
25
+ def to_h
26
+ { kind: kind, retryable: retryable, retry_after: retry_after }.compact
27
+ end
28
+ end
29
+
30
+ KINDS = %i[fatal retryable rate_limited_short rate_limited_long].freeze
31
+
32
+ # Above this a 429 means quota, not RPM (this is what tells the two apart —
33
+ # a short 429 wants a quick retry; a long one is a billing event).
34
+ SHORT_RETRY_LIMIT = 60 # seconds
35
+
36
+ DEFAULTS = {
37
+ retryable: 5,
38
+ rate_limited_short: 10,
39
+ rate_limited_long: 300
40
+ }.freeze
41
+
42
+ # RubyLLM's taxonomy (error.rb), matched by class name so the core stays
43
+ # ruby_llm-free at load time.
44
+ FATAL_ERROR_NAMES = %w[
45
+ RubyLLM::ContextLengthExceededError RubyLLM::BadRequestError
46
+ RubyLLM::UnauthorizedError RubyLLM::PaymentRequiredError RubyLLM::ForbiddenError
47
+ ].freeze
48
+ RATE_LIMITED_ERROR_NAME = "RubyLLM::RateLimitError"
49
+ RETRYABLE_ERROR_NAMES = %w[
50
+ RubyLLM::ServerError RubyLLM::ServiceUnavailableError RubyLLM::OverloadedError
51
+ ].freeze
52
+ RUBY_LLM_ERROR_NAMES = (
53
+ FATAL_ERROR_NAMES + [RATE_LIMITED_ERROR_NAME] +
54
+ RETRYABLE_ERROR_NAMES + ["RubyLLM::Error"]
55
+ ).freeze
56
+
57
+ # Transport failures while talking to the provider: connection
58
+ # refused/reset, DNS, TLS, timeouts (Faraday wraps its own names; the
59
+ # stdlib ones surface from raw sockets).
60
+ TRANSPORT_NAME_PATTERNS = [
61
+ /\AFaraday::/,
62
+ /\ASocketError\z/,
63
+ /\AIOError\z/,
64
+ /\AErrno::/,
65
+ /\ANet::(Read|Open)Timeout\z/,
66
+ /\ATimeout::Error\z/,
67
+ /\AOpenSSL::SSL::SSLError\z/
68
+ ].freeze
69
+
70
+ class << self
71
+ # -> Classification
72
+ def classify(error)
73
+ names = class_names(error)
74
+
75
+ # Non-retryable first (the structural rule): a known fatal is NEVER
76
+ # retried, and an unknown error defaults to fatal — never to retry.
77
+ return fatal if (names & FATAL_ERROR_NAMES).any?
78
+
79
+ return rate_limited(error) if names.include?(RATE_LIMITED_ERROR_NAME)
80
+
81
+ return retryable if (names & RETRYABLE_ERROR_NAMES).any?
82
+ return retryable if transport?(names)
83
+
84
+ # A generic RubyLLM::Error (or a raw HTTP error) still carries the
85
+ # status: 429 and 5xx are retryable regardless of the wrapping class.
86
+ case http_status(error)
87
+ when 429 then rate_limited(error)
88
+ when 500..599 then retryable
89
+ else fatal
90
+ end
91
+ end
92
+
93
+ # True when the error came from the provider call itself (RubyLLM
94
+ # family or transport) — the executor routes these to the :ruby_llm
95
+ # stage with a wrapped classification instead of :unknown.
96
+ def provider_error?(error)
97
+ names = class_names(error)
98
+ (names & RUBY_LLM_ERROR_NAMES).any? || transport?(names)
99
+ end
100
+
101
+ # The typed ProviderError the executor stores and emits.
102
+ def wrap(error)
103
+ c = classify(error)
104
+ Insika::ProviderError.new(
105
+ error.message || error.class.name,
106
+ kind: c.kind, retryable: c.retryable, retry_after: c.retry_after
107
+ )
108
+ end
109
+
110
+ private
111
+
112
+ def fatal
113
+ Classification.new(kind: :fatal, retryable: false, retry_after: nil)
114
+ end
115
+
116
+ def retryable
117
+ Classification.new(kind: :retryable, retryable: true,
118
+ retry_after: DEFAULTS[:retryable])
119
+ end
120
+
121
+ def rate_limited(error)
122
+ ra = retry_after_header(error)
123
+ if ra && ra > SHORT_RETRY_LIMIT
124
+ Classification.new(kind: :rate_limited_long, retryable: true, retry_after: ra)
125
+ else
126
+ Classification.new(kind: :rate_limited_short, retryable: true,
127
+ retry_after: ra || DEFAULTS[:rate_limited_short])
128
+ end
129
+ end
130
+
131
+ def class_names(error)
132
+ ([error.class.name] + Array(error.class.ancestors).map(&:name)).compact
133
+ end
134
+
135
+ def transport?(names)
136
+ names.any? { |n| TRANSPORT_NAME_PATTERNS.any? { |p| p.match?(n) } }
137
+ end
138
+
139
+ # The provider's own Retry-After (seconds), when the error carries a
140
+ # response. All access guarded — a bare double must not raise.
141
+ def retry_after_header(error)
142
+ headers = response_headers(error)
143
+ value = headers && (headers["retry-after"] || headers["Retry-After"])
144
+ value = value.to_s.strip
145
+ value.match?(/\A\d+\z/) ? value.to_i : nil
146
+ end
147
+
148
+ def http_status(error)
149
+ response = error.respond_to?(:response) ? error.response : nil
150
+ status = response && response.respond_to?(:status) ? response.status : nil
151
+ status&.to_i
152
+ end
153
+
154
+ def response_headers(error)
155
+ response = error.respond_to?(:response) ? error.response : nil
156
+ response && response.respond_to?(:headers) ? response.headers : nil
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coercion"
4
+
5
+ module Insika
6
+ # what happens to an inbound message for a session that is ALREADY
7
+ # busy. Today the engine has exactly one answer, "it waits in line"; this names
8
+ # that answer `followup` and adds three others:
9
+ #
10
+ # collect — the message arrived BEFORE the turn started: merge the fragments
11
+ # into one turn (the whole mechanism is a timer at the door).
12
+ # steer — the turn is ALREADY running tools: append the message to the run in
13
+ # flight, at a tool-batch boundary, so the customer's correction lands
14
+ # before the model's next step instead of after the whole run.
15
+ # interrupt — the turn is running and is now answering the wrong question: abandon
16
+ # it at its next boundary and let the new message be its own turn.
17
+ #
18
+ # They never compete for the same message: `collect` only ever touches a turn that
19
+ # has not started; `steer` and `interrupt` only a turn that has, and they differ in
20
+ # whether the run in flight is still worth finishing.
21
+ #
22
+ # Resolution per message, the order EdgeLimiter already documents
23
+ # (`edge_limiter.rb:17`) — configuration over convention:
24
+ #
25
+ # session vars["queue_mode"] — one conversation pinned by an operator
26
+ # profile.limits[:<key>] — per-agent (a PRESENT key wins, incl. nil/0 = off)
27
+ # settings["queue"][<key>] — platform default, editable in the Studio
28
+ # DEFAULTS[<key>] — = today's behavior
29
+ #
30
+ # Every default is off: a bare wiring behaves exactly as it did before this
31
+ # existed. The knobs live in `profile.limits` next to `tool_concurrency` and
32
+ # `turn_timeout` because they are bounds on the same thing — how much work one
33
+ # turn is allowed to absorb.
34
+ class QueuePolicy
35
+ # All four of are delivered, so there is no "specified but unshipped"
36
+ # tier any more — a mode outside this set is a typo, and it is refused rather than
37
+ # approximated. Treating an unknown mode as `followup` would look exactly like a
38
+ # mode that never fires.
39
+ MODES = %i[followup collect steer interrupt].freeze
40
+
41
+ DEFAULTS = {
42
+ queue_mode: :followup,
43
+ debounce_ms: 0, # 0 = no window: dequeue immediately, today's path
44
+ debounce_max_ms: 10_000, # ceiling on the TOTAL deferral (see #debounce_deadline_ms)
45
+ steer_max_messages: 5, # how many messages ONE run may absorb; overflow = followup
46
+ steer_join: nil # nil = the raw text; a template frames it (see #frame)
47
+ }.freeze
48
+
49
+ # The placeholder `steer_join` must carry, so a template that would silently
50
+ # drop the customer's message is a config error and not a lost message.
51
+ JOIN_PLACEHOLDER = "%{message}"
52
+
53
+ attr_reader :mode, :debounce_ms, :debounce_max_ms, :steer_max_messages, :steer_join
54
+
55
+ # profile: an AgentProfile (or nil); settings_store: nil = no platform layer;
56
+ # vars: the session's vars Hash (string keys, as the SessionStore returns).
57
+ def self.resolve(profile, settings_store: nil, vars: nil)
58
+ platform = ((settings_store&.get || {})["queue"] || {})
59
+ limits = profile.respond_to?(:limits) ? (profile.limits || {}) : {}
60
+
61
+ new(
62
+ mode: mode!(pick_mode(vars, limits, platform)),
63
+ debounce_ms: pick(:debounce_ms, limits, platform),
64
+ debounce_max_ms: pick(:debounce_max_ms, limits, platform),
65
+ steer_max_messages: pick(:steer_max_messages, limits, platform),
66
+ steer_join: pick_text(:steer_join, limits, platform)
67
+ )
68
+ end
69
+
70
+ def initialize(mode:, debounce_ms:, debounce_max_ms:,
71
+ steer_max_messages: DEFAULTS[:steer_max_messages], steer_join: nil)
72
+ @mode = mode
73
+ @debounce_ms = [debounce_ms.to_i, 0].max
74
+ # A non-positive ceiling would mean "defer forever", which nobody wants and
75
+ # which a stray 0 in a config would silently buy. Fall back to the default.
76
+ max = debounce_max_ms.to_i
77
+ @debounce_max_ms = max.positive? ? max : DEFAULTS[:debounce_max_ms]
78
+ # 0 (or a negative) is a legitimate "never steer on this agent" — unlike the
79
+ # ceiling above, it forbids rather than defers forever, so it is honored.
80
+ @steer_max_messages = [steer_max_messages.to_i, 0].max
81
+ @steer_join = join!(steer_join)
82
+ end
83
+
84
+ # Does this policy want messages held at the door at all?
85
+ def debounce? = @debounce_ms.positive?
86
+
87
+ # Does this policy merge into a turn that has not started yet?
88
+ def collect? = @mode == :collect
89
+
90
+ # Does this policy append into a turn that is already running? `steer_max_messages`
91
+ # of 0 is the agent saying no, so it answers false rather than steering once and
92
+ # then refusing.
93
+ def steer? = @mode == :steer && @steer_max_messages.positive?
94
+
95
+ # Does this policy abandon the turn in flight? The new message then becomes an
96
+ # ordinary turn of its own — which is why `interrupt`, unlike the two joining modes,
97
+ # needs no verdict field and works on every surface.
98
+ def interrupt? = @mode == :interrupt
99
+
100
+ # The content of the injected message. `steer_join` frames it when an agent needs
101
+ # the model to know this text arrived mid-run ("the customer just added: %{message}");
102
+ # nil — the default — appends exactly what the person typed. A plain gsub, not
103
+ # `format`: the text is a customer's, and a stray `%` in it must not raise.
104
+ def frame(text)
105
+ return text.to_s if @steer_join.nil?
106
+
107
+ @steer_join.gsub(JOIN_PLACEHOLDER, text.to_s)
108
+ end
109
+
110
+ # A mode name -> Symbol, or raise. Blank = the default (an absent key is not
111
+ # an error; a WRONG key is).
112
+ def self.mode!(value)
113
+ return DEFAULTS[:queue_mode] if Coercion.blank?(value)
114
+
115
+ name = value.to_s.strip.downcase.to_sym
116
+ return name if MODES.include?(name)
117
+
118
+ raise Insika::ValidationError,
119
+ "unknown queue_mode: #{value.inspect} (expected #{MODES.join(', ')})"
120
+ end
121
+
122
+ # A present key WINS even carrying nil/0 — "off for this agent", never
123
+ # "inherit the platform default". Same semantics EdgeLimiter documents at
124
+ # `edge_limiter.rb:57`, and for the same reason: an imported pack carrying an
125
+ # explicit null must not silently re-enable a platform behavior.
126
+ def self.pick(key, limits, platform)
127
+ return limits[key].to_i if limits.key?(key)
128
+ return platform[key.to_s].to_i if platform.key?(key.to_s)
129
+
130
+ DEFAULTS[key]
131
+ end
132
+
133
+ # Same rule as #pick for a key whose value is TEXT: `to_i` would turn a template
134
+ # into 0. A present-but-nil key still means "off for this agent".
135
+ def self.pick_text(key, limits, platform)
136
+ return limits[key] if limits.key?(key)
137
+ return platform[key.to_s] if platform.key?(key.to_s)
138
+
139
+ DEFAULTS[key]
140
+ end
141
+
142
+ def self.pick_mode(vars, limits, platform)
143
+ from_vars = (vars || {})["queue_mode"] || (vars || {})[:queue_mode]
144
+ return from_vars if Coercion.present?(from_vars)
145
+ return limits[:queue_mode] if limits.key?(:queue_mode)
146
+
147
+ platform["queue_mode"]
148
+ end
149
+
150
+ private_class_method :pick, :pick_text, :pick_mode
151
+
152
+ private
153
+
154
+ # A template that does not carry the placeholder would drop the customer's message
155
+ # while the turn still looked steered. Refused where the operator is (config time),
156
+ # not at 14:02 on a live conversation.
157
+ def join!(value)
158
+ return nil if Coercion.blank?(value)
159
+
160
+ text = value.to_s
161
+ return text if text.include?(JOIN_PLACEHOLDER)
162
+
163
+ raise Insika::ValidationError,
164
+ "steer_join must contain #{JOIN_PLACEHOLDER} (got #{value.inspect})"
165
+ end
166
+ end
167
+ end