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
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # CONFIGURATION DOMAIN store. Unlike the
7
+ # EXECUTION stores (session/task/checkpoint/pending/memory), this holds the
8
+ # configuration the Studio authors at runtime: agents (profiles), general
9
+ # settings, LLM providers and MCP instances. Scoped KV over any
10
+ # `Insika::Store` — durable when the backend is SQLite (survives a restart,
11
+ # like everything else).
12
+ #
13
+ # logical scope -> physical namespace "config:<scope>". Values are
14
+ # JSON-serializable Hashes (symbol becomes string on the round-trip, per the
15
+ # Store contract; the domain re-symbolizes at the edge — see StoredProfileSource).
16
+ class ConfigStore
17
+ SCOPE_PREFIX = "config"
18
+ # agent_files/skills: the CONTENT of per-agent prompts
19
+ # and shared skills lives in the Store (single source of truth,
20
+ # a SQLite backup), not on disk — disk becomes only seed/import.
21
+ # goldens: authored eval cases — config, like the
22
+ # prompts and skills: the corpus on disk is seed and export, the store is what a
23
+ # deployment runs and what the Studio edits.
24
+ # baselines: the ACCEPTED state of each agent's golden set.
25
+ # Config for the same reason: it is a curated decision ("this is the bar"), the
26
+ # file is its export, and the refinement gate reads it from inside a deployment
27
+ # that has no checkout.
28
+ # agent_skills: the per-agent SPECIALIZATIONS of a shared skill (and
29
+ # agent-private skills). A second scope rather than a composite key in `skills`,
30
+ # so the shared records are untouched by the agent dimension arriving — no
31
+ # migration, and a live deployment keeps serving exactly what it served.
32
+ SCOPES = %w[agents settings llm_providers mcp agent_files skills agent_skills
33
+ system_files tools goldens baselines].freeze
34
+
35
+ class UnknownScope < Insika::Error; end
36
+
37
+ def initialize(store:)
38
+ @store = store
39
+ end
40
+
41
+ # Upsert (last-write-wins). -> value (the same Hash that was passed)
42
+ def put(scope, key, value)
43
+ @store.set(ns(scope), key.to_s, stringify(value))
44
+ value
45
+ end
46
+
47
+ # -> Hash | nil
48
+ def get(scope, key)
49
+ @store.get(ns(scope), key.to_s)
50
+ end
51
+
52
+ # -> bool (did it exist?)
53
+ def delete(scope, key)
54
+ @store.delete(ns(scope), key.to_s)
55
+ end
56
+
57
+ # -> [String] keys of the scope, sorted lexicographically
58
+ def keys(scope)
59
+ @store.list(ns(scope))
60
+ end
61
+
62
+ # -> [Hash] all records of the scope (lexicographic key order)
63
+ def all(scope)
64
+ s = ns(scope)
65
+ @store.list(s).filter_map { |k| @store.get(s, k) }
66
+ end
67
+
68
+ private
69
+
70
+ def ns(scope)
71
+ key = scope.to_s
72
+ raise UnknownScope, "unknown config scope: #{scope.inspect}" unless SCOPES.include?(key)
73
+
74
+ "#{SCOPE_PREFIX}:#{key}"
75
+ end
76
+
77
+ # Normalizes symbol->string BEFORE writing (mirrors MemoryStore#stringify):
78
+ # the backend serializes JSON, and reading back would return strings anyway;
79
+ # normalizing on write keeps the record consistent across backends.
80
+ def stringify(obj)
81
+ case obj
82
+ when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify(v) }
83
+ when Array then obj.map { |v| stringify(v) }
84
+ when Symbol then obj.to_s
85
+ else obj
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Builder output, consumed by the Executor in stage 5.
8
+ # system: String (final concatenation for with_instructions)
9
+ # history: [{role:, content:}] (for seeding the chat)
10
+ # tool_context: String | nil
11
+ # fragments: [ContextFragment] post-cut, in canonical order (audit)
12
+ # budget: { cap:, used:, evicted: [source] }
13
+ ContextPackage = Data.define(:system, :history, :tool_context, :fragments, :budget)
14
+
15
+ # Stage 2 of the pipeline: the Runtime NEVER builds the prompt — it asks the
16
+ # Builder for the package. Implements selection -> fan-out production ->
17
+ # collection/estimation -> budgeting with eviction -> canonical assembly.
18
+ class ContextBuilder
19
+ def initialize(providers:, event_stream:, hooks: Hooks.new, estimator: TokenEstimator)
20
+ @providers = providers
21
+ @event_stream = event_stream
22
+ @hooks = hooks # prompt pair; empty Hooks = no-op
23
+ @estimator = estimator
24
+ end
25
+
26
+ # The :prompt pair is wrapped HERE, not in the Executor: before_prompt
27
+ # can rewrite the ContextRequest (providers run with the modified one);
28
+ # after_prompt can rewrite the assembled ContextPackage. IMPORTANT: the
29
+ # Executor calls only `builder.call(request)` — do NOT wrap it with
30
+ # around(:prompt) again (double-wrapping would fire the hooks twice).
31
+ def call(request)
32
+ @hooks.around(:prompt, request) { |req| build_package(req) }
33
+ end
34
+
35
+ private
36
+
37
+ def build_package(request)
38
+ selected = select_providers(request.profile)
39
+ fragments = estimate_tokens(produce(selected, request))
40
+ cap = request.profile.limits[:context_budget] || 8_000
41
+ fragments, evicted = apply_budget(fragments, cap)
42
+ unless evicted.empty?
43
+ emit_warning("ContextBuilder",
44
+ "budget: #{evicted.size} fragment(s) evicted from #{evicted.uniq}",
45
+ request)
46
+ end
47
+ assemble(fragments, cap, evicted)
48
+ end
49
+
50
+ # Step 1: selection — enabled_for? AND the profile allowlist.
51
+ def select_providers(profile)
52
+ @providers.select do |p|
53
+ p.enabled_for?(profile) && Allowlist.allows?(profile.context_providers, p.id)
54
+ end
55
+ end
56
+
57
+ # Step 2: fan-out production with a BARRIER and a per-provider timeout
58
+ # (with_timeout — never Timeout.timeout). Each provider is a CHILD fiber
59
+ # of the current fiber: cancelling the task cancels the in-flight production.
60
+ def produce(selected, request)
61
+ timeout = request.profile.limits[:provider_timeout] || 5
62
+ tasks = selected.map do |provider|
63
+ child = Async::Task.current.async { |t| t.with_timeout(timeout) { provider.call(request) } }
64
+ [provider, child]
65
+ end
66
+
67
+ fragments = []
68
+ tasks.each do |provider, child|
69
+ fragments.concat(Array(child.wait))
70
+ rescue StandardError => e # Async::TimeoutError is a StandardError; Async::Stop is NOT (propagates)
71
+ handle_provider_failure(provider, e, request)
72
+ end
73
+ fragments
74
+ end
75
+
76
+ # A required provider failing -> ContextError
77
+ # (aborts the turn, mapped by the Executor); an optional one failing ->
78
+ # warning + graceful degradation (fragments omitted, turn proceeds).
79
+ def handle_provider_failure(provider, error, request)
80
+ if provider.required?
81
+ raise ContextError.new("required provider '#{provider.id}' failed: #{error.message}",
82
+ provider: provider.id)
83
+ end
84
+
85
+ emit_warning(provider.id, error.message, request)
86
+ end
87
+
88
+ # Step 3: estimate tokens only when the provider did not report them.
89
+ def estimate_tokens(fragments)
90
+ fragments.map { |f| f.tokens ? f : f.with(tokens: @estimator.estimate(estimable_text(f.content))) }
91
+ end
92
+
93
+ # History fragments carry a Hash {role:, content:} as their content;
94
+ # estimating over the Hash would count the `#to_s` text (":role=>", quotes,
95
+ # symbols), inflating each message and biasing the eviction. Count the values,
96
+ # not the Ruby representation.
97
+ def estimable_text(content)
98
+ case content
99
+ when String then content
100
+ # An eviction unit (R1): a cycle of message Hashes -> sum their text.
101
+ when Array then content.map { |c| estimable_text(c) }.join(" ")
102
+ when Hash then content.values.map(&:to_s).join(" ")
103
+ else content.to_s
104
+ end
105
+ end
106
+
107
+ # Steps 4-5: GLOBAL budget. Cuts non-pinned fragments from lowest priority to
108
+ # highest; ties -> lowest production index first (stable cut: among
109
+ # histories, the oldest drops first). pinned is uncuttable; if pinned alone
110
+ # already exceeds -> ContextError (do not truncate identity).
111
+ def apply_budget(fragments, cap)
112
+ used = fragments.sum(&:tokens)
113
+ return [fragments, []] if used <= cap
114
+
115
+ indexed = fragments.each_with_index.to_a
116
+ cuttable = indexed.reject { |f, _i| f.pinned }.sort_by { |f, i| [f.priority, i] }
117
+ evicted_idx = []
118
+ evicted_sources = []
119
+ cuttable.each do |fragment, index|
120
+ break if used <= cap
121
+
122
+ used -= fragment.tokens
123
+ evicted_sources << fragment.source
124
+ evicted_idx << index
125
+ end
126
+
127
+ if used > cap
128
+ raise ContextError.new(
129
+ "unsolvable budget: pinned fragments (#{used} tokens) exceed the cap (#{cap})",
130
+ provider: "ContextBuilder"
131
+ )
132
+ end
133
+
134
+ survivors = fragments.each_index.reject { |i| evicted_idx.include?(i) }.map { |i| fragments[i] }
135
+ [survivors, evicted_sources]
136
+ end
137
+
138
+ # Step 6: assembly in DETERMINISTIC canonical order.
139
+ def assemble(fragments, cap, evicted)
140
+ system_frags = fragments.select { |f| f.placement == :system }
141
+ .sort_by.with_index { |f, i| [-f.priority, f.source.to_s, i] }
142
+ history_frags = fragments.select { |f| f.placement == :history } # production order (chronological)
143
+ tool_frags = fragments.select { |f| f.placement == :tool_context }
144
+
145
+ system = system_frags.map(&:content).join("\n\n")
146
+ history = history_frags.map(&:content)
147
+ tool_context = tool_frags.empty? ? nil : tool_frags.map(&:content).join("\n\n")
148
+
149
+ canonical = system_frags + history_frags + tool_frags
150
+ ContextPackage.new(
151
+ system: system, history: history, tool_context: tool_context,
152
+ fragments: canonical, budget: { cap: cap, used: canonical.sum(&:tokens), evicted: evicted }
153
+ )
154
+ end
155
+
156
+ # :provider_warning. The Builder does not know task_id/seq (correlation is
157
+ # the Executor's job) — emits with what it has; Event#to_h does meta.compact.
158
+ def emit_warning(provider_id, message, request)
159
+ @event_stream.emit(Insika::Event.new(
160
+ type: :provider_warning,
161
+ data: { provider: provider_id, message: message },
162
+ meta: { session_id: request.session&.id, at: Time.now.utc.iso8601 }
163
+ ))
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ # Base for providers that inject level 1 of a catalog (skills, deferred
6
+ # tools) as a :system fragment. The subclass says WHICH entries and the
7
+ # priority; the fragment assembly (format -> skip if empty -> a single
8
+ # fragment with source = the provider id) is shared.
9
+ class CatalogProvider < ContextProvider
10
+ def initialize(catalog:)
11
+ @catalog = catalog
12
+ end
13
+
14
+ def call(request)
15
+ block = @catalog.format_for_prompt(entries(request))
16
+ return [] if block.empty?
17
+
18
+ [ContextFragment.build(content: block, placement: :system,
19
+ priority: priority, source: id)]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Unit of context produced by a provider.
5
+ # SHARED type (Insika::, not Insika::Context::).
6
+ # placement: :system | :history | :tool_context
7
+ # priority: Integer; higher = more important (survives cuts)
8
+ # tokens: Integer | nil; estimated by the Builder when nil
9
+ # source: String — provider id (audit)
10
+ # pinned: true -> uncuttable in the budget (e.g. identity)
11
+ # labels: [{ "name" =>, "reason" => }] — WHAT this fragment carries and WHY,
12
+ # as ids. Content-FREE by contract, so the context trace can report
13
+ # which skills a turn injected without storing a byte of the bodies.
14
+ # [] = nothing to name (the default for every provider that has no
15
+ # natural id, e.g. the identity prompt).
16
+ #
17
+ # The REASON is the point. A name alone answers "was something
18
+ # injected"; the operator's actual question is "which skill did I
19
+ # trigger, and why is it here" — `eager` (the agent always wants it),
20
+ # `trigger:<matched phrase>` (this message asked for it), or absent
21
+ # for a body a plugin supplied. String keys because these labels are
22
+ # written to the context trace and to events as JSON: the round-trip
23
+ # is then the identity, and no reader has to defend against both.
24
+ ContextFragment = Data.define(:content, :placement, :priority, :tokens,
25
+ :source, :pinned, :labels) do
26
+ def self.build(content:, placement:, source:, priority: 50, tokens: nil,
27
+ pinned: false, labels: [])
28
+ new(content: content, placement: placement, priority: priority,
29
+ tokens: tokens, source: source, pinned: pinned,
30
+ labels: Array(labels).map { |l| label(l) })
31
+ end
32
+
33
+ # A bare String is still a valid label (a provider that has an id but no reason
34
+ # to give) — it normalizes to a reason-less entry rather than being rejected.
35
+ def self.label(raw)
36
+ return { "name" => raw.to_s }.freeze unless raw.is_a?(Hash)
37
+
38
+ name = (raw[:name] || raw["name"]).to_s
39
+ reason = raw[:reason] || raw["reason"]
40
+ { "name" => name, "reason" => reason&.to_s }.compact.freeze
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ # Precedence ladder for context fragments — SINGLE SOURCE of the order
6
+ # (trust boundary). Higher = more authority: appears earlier
7
+ # in the system prompt and survives budget cuts. Providers reference these
8
+ # constants instead of loose numbers, so the boundary is an auditable
9
+ # contract in a single place (trust_boundary_spec locks the order).
10
+ #
11
+ # Contract: identity and guardrails go PINNED at the top; TURN
12
+ # injections (request_context — the consumer's tenant/vars) sit at the
13
+ # BOTTOM and are sacrificed FIRST under budget. Identity (pinned) is NEVER
14
+ # truncated. A prompt injection riding in via turn data is DATA, not
15
+ # authority — it does not override IDENTITY/SOUL, and security decisions
16
+ # (tool allow/deny, egress, approvals) live in the engine/profile, never in
17
+ # the injected block.
18
+ module Priority
19
+ IDENTITY = 100 # IDENTITY/SOUL (Prompt) — pinned
20
+ PROMPT_REF = 90 # Prompt Catalog guardrails/refs (Prompt) — pinned
21
+ SKILL_BODY = 85 # <active_skill> trigger-matched body (SkillTrigger)
22
+ SKILL = 80 # <available_skills> level 1 (Skill)
23
+ MEMORY = 75 # <memory> read path (Memory)
24
+ TOOL_SEARCH = 70 # <available_tools> level 1 (ToolSearch)
25
+ HISTORY_MAX = 79 # history ceiling by recency (Session)
26
+ HISTORY_BASE = 60 # history base; +idx up to the ceiling (Session)
27
+ REQUEST = 40 # <request_context> — turn injection, the most cuttable
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Base class for a context provider. Concrete
5
+ # subclasses live in Insika::Context::Providers. The base is
6
+ # deliberately minimal: `required?` lives on the provider, not the wiring.
7
+ class ContextProvider
8
+ def id = self.class.name # override for a stable name
9
+ def required? = false # true -> failure aborts the turn
10
+ def enabled_for?(_profile) = true
11
+ def call(_request) = [] # -> [ContextFragment]; may do IO
12
+ end
13
+
14
+ # Input for the provider contract.
15
+ # session: SessionStore::Session | nil
16
+ # checkpoint: Checkpoint | nil (present on ResumeTask — history comes from it)
17
+ ContextRequest = Data.define(:session, :message, :profile, :tenant, :vars,
18
+ :checkpoint)
19
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Read path for cross-session memory. Thin adapter over
7
+ # the MemoryStore — same pattern as the Skill/ToolSearch provider: one
8
+ # `:system` fragment with the tenant's facts + recent notes. Deterministic
9
+ # (no embeddings/ranking).
10
+ class Memory < ContextProvider
11
+ def initialize(store:, notes_limit: 10)
12
+ @store = store
13
+ @notes_limit = notes_limit
14
+ end
15
+
16
+ # Per-agent opt-in. The Builder still applies the `context_providers`
17
+ # allowlist on top (two gates, like the other providers).
18
+ def enabled_for?(profile) = !!profile.memory
19
+
20
+ # required? == false (default): a failure (store unavailable) becomes a
21
+ # :provider_warning + graceful degradation — never aborts the turn.
22
+ def call(request)
23
+ tenant = memory_tenant(request)
24
+ facts = @store.facts(tenant: tenant)
25
+ notes = @store.notes(tenant: tenant, limit: @notes_limit)
26
+ return [] if facts.empty? && notes.empty?
27
+
28
+ # priority MEMORY (75): between skills (80) and deferred tools (70) in
29
+ # the sacrifice order. pinned false (cuttable under a tight budget).
30
+ [ContextFragment.build(content: format_block(facts, notes),
31
+ placement: :system, priority: Context::Priority::MEMORY, source: id)]
32
+ end
33
+
34
+ private
35
+
36
+ # Engine memory scope: an EXPLICIT tenant from the Command wins
37
+ # (multi-merchant override); otherwise the SESSION (=chat) — engine-owner
38
+ # memory is per-chat. No session (one-shot) and no tenant -> nil (MemoryStore
39
+ # applies _default). Symmetric to the write path (`state.tenant` in the Executor).
40
+ def memory_tenant(request)
41
+ explicit = request.respond_to?(:tenant) ? request.tenant : nil
42
+ return explicit if explicit
43
+
44
+ request.respond_to?(:session) ? request.session&.id : nil
45
+ end
46
+
47
+ # Passive <memory> (no instruction — the HOW of writing lives in the `remember` tool).
48
+ def format_block(facts, notes)
49
+ lines = facts.map { |f| %( <fact key="#{f.key}">#{f.value}</fact>) }
50
+ lines += notes.map { |n| " <note>#{n.text}</note>" }
51
+ <<~BLOCK.strip
52
+ <memory>
53
+ #{lines.join("\n")}
54
+ </memory>
55
+ BLOCK
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Absorbs SystemPrompt + SOUL.md. Identity is
7
+ # PINNED, priority 100 — never cut. required?: an agent without identity is
8
+ # a WRONG agent, not a degraded one. prompt_refs: priority 90 pinned
9
+ # fragments, from the PromptCatalog (catalog defaults to nil).
10
+ #
11
+ # PER-AGENT identity. `profile.prompt_files` (file names)
12
+ # wins over the wiring's `files:` — this fixes the limitation of a new
13
+ # agent inheriting Bia's prompt. The content comes from `agent_files`
14
+ # (AgentFileStore, lives in the Store), with a File.read fallback
15
+ # for on-disk paths (compat/seed). Without prompt_files -> uses the wiring's
16
+ # `files:` (deployment default; byte-for-byte parity).
17
+ class Prompt < ContextProvider
18
+ def initialize(base: "", files: [], catalog: nil, agent_files: nil, system_files: nil)
19
+ @base = base
20
+ @files = Array(files)
21
+ @catalog = catalog
22
+ @agent_files = agent_files
23
+ @system_files = system_files
24
+ end
25
+
26
+ def required? = true
27
+
28
+ def call(request)
29
+ fragments = []
30
+ identity = build_identity(request.profile)
31
+ unless identity.empty?
32
+ fragments << ContextFragment.build(content: identity, placement: :system,
33
+ priority: Context::Priority::IDENTITY, source: id, pinned: true)
34
+ end
35
+ fragments.concat(ref_fragments(request.profile))
36
+ fragments
37
+ end
38
+
39
+ private
40
+
41
+ # Migrates the SystemPrompt#build concatenation INTACT (without skills_block).
42
+ # A SINGLE fragment preserves the internal base->files order (sorting
43
+ # acts only BETWEEN fragments) and guarantees byte-for-byte parity.
44
+ #
45
+ # profile.prompt_files (names) wins over @files (wiring): an agent with
46
+ # its own identity does not inherit the deployment's. Each source resolves
47
+ # via AgentFileStore (per agent) OR File.read (on-disk path) — in that
48
+ # order. Without prompt_files, falls back to the wiring's @files.
49
+ def build_identity(profile)
50
+ parts = [@base]
51
+ parts.concat(system_parts) # global system files, for EVERY agent
52
+ # The agent's OWN inline identity (`base_prompt` — what the DSL's
53
+ # `instructions` and a pack's manifest set). It was stored, round-tripped
54
+ # and advertised on the A2A agent card while never reaching the model:
55
+ # every root wires `base: ""`, so an agent whose identity was inline had
56
+ # NO identity at all, and a chatty model answered plausibly enough to hide
57
+ # it. Additive to prompt_files, not exclusive: an agent may carry both.
58
+ parts << profile&.base_prompt.to_s
59
+ sources = Array(profile&.prompt_files)
60
+ if sources.empty?
61
+ @files.each { |f| parts << File.read(f, encoding: "UTF-8") if File.exist?(f) }
62
+ else
63
+ sources.each { |src| parts << read_source(profile&.id, src.to_s) }
64
+ end
65
+ parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
66
+ end
67
+
68
+ # GLOBAL system files: apply to every agent,
69
+ # injected BEFORE the individual identity. Empty/absent store -> [] ->
70
+ # a prompt byte-for-byte identical to before (the injection only exists if
71
+ # the operator authored something). Lexicographic order (SystemFileStore#list).
72
+ def system_parts
73
+ return [] unless @system_files
74
+
75
+ @system_files.list.map { |name| @system_files.read(name).to_s }
76
+ end
77
+
78
+ # Per-agent store first, disk second (compat/seed).
79
+ def read_source(agent_id, src)
80
+ stored = agent_id && @agent_files&.read(agent_id, src)
81
+ return stored if stored
82
+ return File.read(src, encoding: "UTF-8") if File.exist?(src)
83
+
84
+ ""
85
+ end
86
+
87
+ def ref_fragments(profile)
88
+ refs = Array(profile.prompt_refs)
89
+ return [] if refs.empty?
90
+
91
+ refs.map do |name|
92
+ entry = @catalog&.find(name.to_s)
93
+ unless entry
94
+ raise ContextError.new("prompt_ref '#{name}' not found in the Prompt Catalog",
95
+ provider: id)
96
+ end
97
+
98
+ ContextFragment.build(content: entry.body, placement: :system,
99
+ priority: Context::Priority::PROMPT_REF, source: id, pinned: true)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # :system fragment with turn metadata (tenant, relevant vars).
7
+ # Nothing if there is no metadata. priority 40. Not required?
8
+ # (metadata may degrade).
9
+ class Request < ContextProvider
10
+ def call(request)
11
+ lines = []
12
+ lines << "tenant: #{request.tenant}" if request.tenant
13
+ # "history" is transcript (consumed by the Session provider), not turn
14
+ # metadata — it does not leak into the system's request_context. Keys
15
+ # prefixed with "__" are INTERNAL slots (e.g. the per-chat model pin
16
+ # "__llm__") — reserved, never rendered to the model.
17
+ request.vars.to_h.each do |k, v|
18
+ next if k.to_s == "history" || k.to_s.start_with?("__")
19
+
20
+ lines << "#{k}: #{v}"
21
+ end
22
+ return [] if lines.empty?
23
+
24
+ [ContextFragment.build(
25
+ content: "<request_context>\n#{lines.join("\n")}\n</request_context>",
26
+ placement: :system, priority: Context::Priority::REQUEST, source: id
27
+ )]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end