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
data/lib/insika/dsl.rb ADDED
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pack"
4
+
5
+ module Insika
6
+ # Public Ruby DSL — the OSS "business card":
7
+ #
8
+ # agent = Insika.agent("assistant") do
9
+ # model "deepseek-v4-flash"
10
+ # instructions "You are a concise, friendly assistant."
11
+ # end
12
+ # puts agent.reply("hi, what can you do?") # one turn, in-process
13
+ # agent.serve # control UI + /v1 on :9292
14
+ #
15
+ # It is THIN SUGAR that GENERATES the data (a Insika::Pack), never a bypass of
16
+ # config-over-code (COMPETITIVE-ANALYSIS). `Insika.agent { … }.to_pack`
17
+ # is the same portable artifact the PackImporter consumes at runtime — the DSL
18
+ # and a hand-written pack produce the SAME profile (the parity spec proves it),
19
+ # because BOTH go through the standard import → StoredProfileSource round-trip.
20
+ #
21
+ # Nothing here loads ruby_llm or the HTTP server: `require "insika"` stays light.
22
+ # The runtime (chat/serve) is pulled in lazily by Definition (dsl/runtime.rb).
23
+ module DSL
24
+ module_function
25
+
26
+ # Insika.agent("id") { … } → Definition (see #agent below on the module).
27
+ def agent(id, &block)
28
+ Builder.new(id).build(&block)
29
+ end
30
+
31
+ # Insika.system { agent("a") { … }; agent("b") { … } } → System.
32
+ def system(&block)
33
+ SystemBuilder.new.build(&block)
34
+ end
35
+
36
+ # Insika.embed(backend:) { … } → System (see #embed below on the module).
37
+ def embed(backend:, &block)
38
+ SystemBuilder.new.build(backend: backend, &block)
39
+ end
40
+
41
+ # Collects several agents into ONE runtime. A single agent is a Definition;
42
+ # more than one needs a container, because delegation (`subagents`) and any
43
+ # multi-agent pattern only mean something when the children live in the same
44
+ # graph. It adds no new engine path: each agent is still its own Pack,
45
+ # imported through the standard PackImporter.
46
+ class SystemBuilder
47
+ def initialize
48
+ @definitions = []
49
+ @workflows = []
50
+ @runtime = {}
51
+ end
52
+
53
+ def build(backend: nil, &block)
54
+ instance_eval(&block) if block
55
+ raise ArgumentError, "Insika.system needs at least one agent" if @definitions.empty?
56
+
57
+ System.new(definitions: @definitions, workflows: @workflows, runtime: @runtime, backend: backend)
58
+ end
59
+
60
+ # Declares one agent — the SAME block the standalone `Insika.agent` takes.
61
+ # Returns its Definition, so a script can keep a handle if it wants one.
62
+ def agent(id, &block)
63
+ definition = Builder.new(id).build(&block)
64
+ if @definitions.any? { |d| d.id == definition.id }
65
+ raise ArgumentError, "duplicate agent id in system: #{definition.id}"
66
+ end
67
+
68
+ @definitions << definition
69
+ definition
70
+ end
71
+
72
+ # Declares a WORKFLOW: deterministic Ruby orchestrating agent turns, for the
73
+ # shapes a single tool-loop should not decide on its own — chaining, routing,
74
+ # evaluate-and-retry. It is registered in the same WorkflowRegistry a
75
+ # deployment uses, so it gets a durable run (the run id IS a Task),
76
+ # `:workflow_started`/`:workflow_completed` on the event stream, and — when
77
+ # served — `GET /v1/workflows` + `POST /v1/workflows/:name`.
78
+ #
79
+ # workflow "draft", input: { type: "object", required: ["topic"], … } do |input, ctx|
80
+ # draft = ctx.ask("writer", "Write about #{input['topic']}")
81
+ # ctx.ask("editor", "Tighten this:\n#{draft}")
82
+ # end
83
+ #
84
+ # `input:`/`output:` take a JSON Schema Hash (validated by the engine's
85
+ # zero-dep validator) or any dry-schema-compatible `#call`-able. A bad input
86
+ # is refused synchronously, with NO run created.
87
+ def workflow(name, description: nil, input: nil, output: nil, &block)
88
+ raise ArgumentError, "workflow '#{name}' needs a block" if block.nil?
89
+
90
+ name = name.to_s
91
+ raise ArgumentError, "duplicate workflow in system: #{name}" if @workflows.any? { |w| w[:name] == name }
92
+
93
+ @workflows << { name: name, description: description,
94
+ input_schema: input, output_schema: output, block: block }
95
+ name
96
+ end
97
+
98
+ # System-wide runtime knobs (NOT part of any pack): they configure the LLM
99
+ # clients for every agent. A per-agent `provider` still wins for that agent;
100
+ # this is the default and the place to put a shared key.
101
+ def provider(name) = @runtime[:provider] = name.to_s
102
+ def api_key(value) = @runtime[:api_key] = value.to_s
103
+ def api_base(value) = @runtime[:api_base] = value.to_s
104
+ end
105
+
106
+ # Collects the declarations and emits a Insika::Pack. Declarations map 1:1 to
107
+ # the pack manifest (AgentProfile.build attrs) + the pack's files/skills/tools —
108
+ # so what you write is exactly the data the engine stores.
109
+ class Builder
110
+ def initialize(id)
111
+ @id = id.to_s
112
+ @config = {}
113
+ @files = {}
114
+ @skills = {}
115
+ @tools = []
116
+ # Auto-enable the allowlist policies: harmless when the allowlist is nil=all,
117
+ # correct once you restrict tools/skills. Visible in #to_pack — no hidden magic.
118
+ @config[:policies] = %i[tool_allowlist skill_allowlist]
119
+ @runtime = {} # non-pack knobs (llm provider/key/base) consumed by the runtime
120
+ end
121
+
122
+ def build(&block)
123
+ instance_eval(&block) if block
124
+ Definition.new(pack: to_pack, runtime: @runtime)
125
+ end
126
+
127
+ # --- identity & model ------------------------------------------------
128
+ def model(name) = @config[:model] = name.to_s
129
+
130
+ # Provider for both the profile AND the RubyLLM configuration at run time.
131
+ def provider(name)
132
+ @config[:provider] = name.to_s
133
+ @runtime[:provider] ||= name.to_s
134
+ end
135
+
136
+ def instructions(text) = @config[:base_prompt] = text.to_s
137
+ alias_method :prompt, :instructions
138
+
139
+ # An extra prompt FILE (identity fragment). Name = the file name (e.g. "SOUL.md").
140
+ def prompt_file(name, content)
141
+ @files[name.to_s] = content.to_s
142
+ end
143
+
144
+ # --- tools -----------------------------------------------------------
145
+ # tools "a", "b" → allowlist [names]. Not called → nil = all (parity).
146
+ def tools(*names)
147
+ @config[:tools_allow] = names.flatten.map(&:to_s)
148
+ end
149
+
150
+ def deny_tools(*names)
151
+ @config[:tools_deny] = names.flatten.map(&:to_s)
152
+ end
153
+
154
+ # A DATA-DEFINED (declarative HTTP) tool — pure config-over-code. `defn` is a
155
+ # ToolDefinition hash (name/description/parameters/binding…). Its name is
156
+ # auto-added to the allowlist so the agent can call its own tool.
157
+ def data_tool(defn)
158
+ h = defn.transform_keys(&:to_s)
159
+ @tools << h
160
+ name = h["name"].to_s
161
+ (@config[:tools_allow] ||= []) << name unless name.empty? || Array(@config[:tools_allow]).include?(name)
162
+ h
163
+ end
164
+
165
+ # --- skills ----------------------------------------------------------
166
+ # skill "escalate", "<full SKILL.md>" — or —
167
+ # skill "escalate", description: "…", instructions: "…"
168
+ # The name is auto-added to the agent's skill allowlist.
169
+ def skill(name, content = nil, description: nil, instructions: nil)
170
+ n = name.to_s
171
+ @skills[n] = normalize_skill(n, content, description, instructions)
172
+ (@config[:skills] ||= []) << n unless @config.fetch(:skills, []).include?(n)
173
+ n
174
+ end
175
+
176
+ # skills_eager — turns progressive disclosure off for THIS agent, wholly or in
177
+ # part. The body of an eager skill is in the prompt on every turn, so its
178
+ # activation is not a decision and cannot be missed; it is paid for on every
179
+ # turn, so measure the bodies against `context_budget` first (a stable position
180
+ # makes them a cacheable prefix).
181
+ #
182
+ # skills_eager # every allowed skill
183
+ # skills_eager "formato", "markers" # exactly these
184
+ # skills_eager false # none (the default)
185
+ #
186
+ # A LIST and not a per-skill flag because skills are shared: `escalation-to-human`
187
+ # sits in several allowlists, and one flag on the skill would force one decision
188
+ # onto every agent holding it.
189
+ def skills_eager(*names)
190
+ flat = names.flatten
191
+ @config[:skills_eager] =
192
+ if flat.empty? then true
193
+ elsif flat == [true] || flat == [false] then flat.first
194
+ else flat.map(&:to_s)
195
+ end
196
+ end
197
+
198
+ # --- delegation ------------------------------------------------------
199
+ # subagents "security", "performance" → the child agents this one MAY
200
+ # spawn. CAPACITY field: opt-in, never inherited, and the ids
201
+ # must be agents of the same system (`Insika.system { … }`) or already in
202
+ # the store. Present ⇒ the engine wires `spawn_subagent`/`spawn_subagents`.
203
+ def subagents(*ids)
204
+ @config[:subagents] = ids.flatten.map(&:to_s)
205
+ end
206
+
207
+ # --- knobs -----------------------------------------------------------
208
+ def memory(on = true) = @config[:memory] = on
209
+
210
+ # Spend caps per calendar window (WS2): daily/monthly token budgets for
211
+ # this agent, per (tenant, agent) when multi-tenant. HARD is the default:
212
+ # absent `soft:` (or `soft: false`) turns the cap into a hard wall (the
213
+ # turn fails with budget_exceeded + retry_after); `soft: true` warns once
214
+ # per window and keeps running.
215
+ # budget daily: 100_000, monthly: 2_000_000, soft: false
216
+ def budget(hash) = (@config[:budget] ||= {}).merge!(hash.transform_keys(&:to_s))
217
+
218
+ # Provider-interaction reliability, as DATA (WS3): retries + exponential
219
+ # backoff on transient failures, a fallback model chain (mid-turn
220
+ # rotation), and a circuit breaker per (tenant, provider/model) that
221
+ # fail-fasts once the window trips. `fallback`/`circuit_breaker` entries
222
+ # are "provider/model" refs or plain model ids.
223
+ # reliability retries: 3, backoff: "exponential",
224
+ # fallback: ["gpt-4o-mini"], circuit_breaker: { after: 10, within: 60, cooldown: 300 }
225
+ def reliability(hash)
226
+ (@config[:reliability] ||= {}).merge!(hash.transform_keys(&:to_s))
227
+ end
228
+
229
+ # Operator alert delivery (WS6): POST this agent's budget_warning /
230
+ # breaker_open / delivery_failed events to the webhook, as JSON.
231
+ # alerts webhook: "https://ops.example.com/insika-alerts"
232
+ def alerts(hash) = (@config[:alerts] ||= {}).merge!(hash.transform_keys(&:to_s))
233
+
234
+ # The agent may signal it cannot proceed (WS5): when on, the model
235
+ # can call `signal_stuck`, which ends the turn with `outcome: :stuck` + a final
236
+ # message + a `:turn_stuck` event. What "stuck" means is the consumer's call.
237
+ # stuck_signal true
238
+ def stuck_signal(on = true) = @config[:stuck_signal] = on
239
+
240
+ # Mechanical tool-result dedupe in the replayed history
241
+ # (no-LLM compaction, apt for bloated transcripts). CHANGES WHAT THE MODEL
242
+ # SEES: repeated identical tool results collapse to a back-reference.
243
+ def tool_output_compression(on = true) = @config[:tool_output_compression] = on
244
+
245
+ # Content-safety guardrails — opt-in and configurable per agent.
246
+ # Pure config-over-code: the hash is stored on the profile and consumed by
247
+ # Safety::Config.from_profile. Merges, so repeated calls accumulate.
248
+ # guardrails input: true, output: true, strictness: "medium",
249
+ # moderator: "deepseek/deepseek-v4-flash",
250
+ # responses: { "injection" => "I can't help with that." }
251
+ def guardrails(hash) = (@config[:guardrails] ||= {}).merge!(hash.transform_keys(&:to_s))
252
+
253
+ # Refinement — how the agent's own instruction files may be
254
+ # improved from real traffic. Same config-over-code shape as `guardrails`;
255
+ # omitting it entirely leaves the agent report-only (writes nothing).
256
+ # refine mode: "propose", window: { last_sessions: 200 }, files: %w[TOOLS.md],
257
+ # proposers: ["deepseek/deepseek-v4-flash", "gpt-5-mini"],
258
+ # budget: { tokens: 200_000 }
259
+ def refine(hash) = (@config[:refinement] ||= {}).merge!(hash.transform_keys(&:to_s))
260
+
261
+ # Facts about THIS deployment that are not tools, so an eval
262
+ # case can declare what it needs and be skipped where it is absent instead of
263
+ # failing for the wrong reason.
264
+ # declares "promotions", "human_handoff"
265
+ def declares(*names)
266
+ (@config[:capabilities_declared] ||= []).concat(names.flatten.map(&:to_s))
267
+ end
268
+
269
+ # Which internal channels may cross to the CUSTOMER. Both off by default: the
270
+ # answer is the answer, and the provider's reasoning (`thinking`) or the model
271
+ # narrating its tool loop (`intermediate`) is for the Studio and the trace.
272
+ # Each opted-in channel gets its OWN frame type at `/v1/responses` — never the
273
+ # answer's — so a consumer that only reads the answer is unaffected either way.
274
+ # edge_stream thinking: true, intermediate: false
275
+ def edge_stream(hash) = (@config[:edge_stream] ||= {}).merge!(hash.transform_keys(&:to_s))
276
+
277
+ # LLM generation params. `param:temperature, 0.2` or `params(...)`.
278
+ def param(key, value) = (@config[:params] ||= {})[key.to_sym] = value
279
+ def params(hash) = (@config[:params] ||= {}).merge!(hash.transform_keys(&:to_sym))
280
+ def temperature(value) = param(:temperature, value)
281
+ def max_tokens(value) = param(:max_tokens, value)
282
+
283
+ # Per-agent limits (timeouts/budgets). `limit :turn_timeout, 120` or `limits(...)`.
284
+ def limit(key, value) = (@config[:limits] ||= {})[key.to_sym] = value
285
+ def limits(hash) = (@config[:limits] ||= {}).merge!(hash.transform_keys(&:to_sym))
286
+
287
+ def policies(*names) = @config[:policies] = names.flatten.map(&:to_sym)
288
+ def metadata(hash) = (@config[:metadata] ||= {}).merge!(hash.transform_keys(&:to_s))
289
+
290
+ # --- runtime (LLM provider) config — NOT part of the pack ------------
291
+ # Configures RubyLLM at chat/serve time. Defaults: provider = the agent's
292
+ # provider; key = ENV["<PROVIDER>_API_KEY"].
293
+ def api_key(value) = @runtime[:api_key] = value.to_s
294
+ def api_base(value) = @runtime[:api_base] = value.to_s
295
+
296
+ # The generated portable artifact — the heart of "generates the data".
297
+ def to_pack
298
+ Insika::Pack.from_h(
299
+ config: @config.merge(id: @id),
300
+ files: @files, skills: @skills, tools: @tools
301
+ )
302
+ end
303
+
304
+ private
305
+
306
+ # Ensure the skill body is a valid SKILL.md (YAML frontmatter with `name`),
307
+ # which is what SkillCatalog parses. Raw content with frontmatter passes
308
+ # through untouched; a bare body / structured args get wrapped.
309
+ def normalize_skill(name, content, description, instructions)
310
+ return content.to_s if content.is_a?(String) && content.lstrip.start_with?("---")
311
+
312
+ body = (instructions || content).to_s
313
+ desc = (description || first_line(body) || name).to_s
314
+ <<~SKILL
315
+ ---
316
+ name: #{name}
317
+ description: #{desc}
318
+ ---
319
+
320
+ #{body}
321
+ SKILL
322
+ end
323
+
324
+ def first_line(text) = text.to_s.strip.lines.first&.strip
325
+ end
326
+ end
327
+
328
+ module_function
329
+
330
+ # Top-level entry point (see Insika::DSL). Returns a Insika::DSL::Definition.
331
+ def agent(id, &block)
332
+ DSL.agent(id, &block)
333
+ end
334
+
335
+ # Several agents in one runtime — the shape every multi-agent pattern needs
336
+ # (delegation, fan-out/fan-in, routing). Returns a Insika::DSL::System.
337
+ def system(&block)
338
+ DSL.system(&block)
339
+ end
340
+
341
+ # the front door for MOUNTING Insika into an app you already
342
+ # have. Same block as `Insika.system`, one added obligation: the caller names
343
+ # the store, so the graph stops discovering it from `INSIKA_DB` and two graphs
344
+ # in one process can no longer read each other's sessions.
345
+ #
346
+ # INSIKA = Insika.embed(backend: Insika::Stores::SQLite.new(path: "storage/insika.sqlite3")) do
347
+ # agent "support" do
348
+ # model "deepseek-v4-flash"
349
+ # instructions "…"
350
+ # end
351
+ # end
352
+ # # config/routes.rb — mount the /v1 transport as a value:
353
+ # mount Insika::Server.rack_app(INSIKA, token: ENV.fetch("INSIKA_TOKEN")), at: "/ai"
354
+ #
355
+ # It is a thin front door over the SAME assembly `Insika.system` uses — there is
356
+ # one pipeline, and the parity spec holds it to that. What an embedded
357
+ # graph owns, and what it still shares with the process, is docs/EMBEDDING.md.
358
+ def embed(backend:, &block)
359
+ DSL.embed(backend: backend, &block)
360
+ end
361
+ end
362
+
363
+ require_relative "dsl/definition"
364
+ require_relative "dsl/system"
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "middleware"
4
+ require_relative "coercion"
5
+
6
+ module Insika
7
+ # The production edge: THE named place where volume/cost
8
+ # abuse is cut. A Middleware with two independent limits, both OPT-IN
9
+ # (nil/0 = off — a bare wiring behaves exactly as before):
10
+ #
11
+ # · chat rate limit — turn ATTEMPTS per chat per window. Counted on entry
12
+ # (a blocked attempt still counts), so a flood keeps hitting the wall.
13
+ # · agent token ceiling — total tokens per agent per window. Checked on entry
14
+ # against the accumulated ledger; the turn's own usage is recorded AFTER the
15
+ # terminal returns (the Middleware wraps stages 5-9, so state.usage is set).
16
+ # · calendar budget — WS2: `AgentProfile#budget` caps the spend per
17
+ # (tenant, agent) over CALENDAR windows (daily/monthly), on the
18
+ # BudgetLedger. Hard (default): crossing the cap raises the typed
19
+ # Insika::BudgetExceeded (the envelope quotes budget_exceeded +
20
+ # retry_after); soft: crossing warns instead — ONE budget_warning event
21
+ # per window plus a note injected into the context. Crossing `alert_at`
22
+ # (default 0.8 of the cap) warns the same way, before the wall. The turn's
23
+ # billed spend (input+output+cached+cache_creation — the A4 rule) lands on
24
+ # the windows after the terminal.
25
+ #
26
+ # Config resolution, per turn (configuration over convention):
27
+ # profile.limits[:chat_rate_limit / :agent_token_ceiling] — per-agent override
28
+ # settings["edge"] — platform default
29
+ # A per-agent 0 explicitly disables a platform default for that agent.
30
+ #
31
+ # On breach it uses the graceful-halt contract: halt_response
32
+ # (the safe reply) + guardrail_block (audit -> :guardrail_blocked) and does NOT
33
+ # call `nxt` — the turn completes with ZERO LLM calls. It sits BEFORE the
34
+ # InputGuardrail in the stack so a flood can't spend the LLM moderator either.
35
+ # The BUDGET breach is the ONE deliberate exception: it is a typed failure
36
+ # (BudgetExceeded), not a customer-facing reply — the operator wants the
37
+ # envelope to say "budget" and quote when the window rolls, not to hand the
38
+ # customer a cost message.
39
+ class EdgeLimiter < Middleware
40
+ CHAT_KIND = "chat"
41
+ TOKENS_KIND = "tokens"
42
+
43
+ DEFAULT_CHAT_WINDOW = 60 # seconds
44
+ DEFAULT_TOKEN_WINDOW = 86_400 # seconds (daily ceiling)
45
+
46
+ # Neutral fallback, same contract as Safety::SafeResponses (pt-BR — the
47
+ # pilot's language; override via settings edge.limit_response).
48
+ DEFAULT_RESPONSE = "Estou recebendo muitas mensagens agora. Aguarde um " \
49
+ "momento e tente novamente, por favor."
50
+
51
+ def initialize(ledger:, settings_store: nil, budget_ledger: nil, event_stream: nil)
52
+ @ledger = ledger
53
+ @settings = settings_store
54
+ # WS2: the calendar-window ledger. nil = budget off (parity — the bare
55
+ # wiring is byte-identical to before).
56
+ @budget_ledger = budget_ledger
57
+ @event_stream = event_stream
58
+ end
59
+
60
+ def call(state, &nxt)
61
+ edge = platform_edge
62
+ limits = state.profile.limits || {}
63
+ # A resume (crash/pause recovery) re-enters the pipeline for a turn that was
64
+ # ALREADY admitted: re-counting it would swallow a legitimate message with
65
+ # the rate-limit reply exactly when the window is saturated. Entry checks
66
+ # are skipped; the turn's usage still lands on the ledger below.
67
+ resumed = state.resumed
68
+
69
+ if !resumed && (limit = positive(limits.key?(:chat_rate_limit) ? limits[:chat_rate_limit] : edge["chat_rate_limit"]))
70
+ breach = check_chat_rate(state, limit, edge)
71
+ return block(state, edge, **breach) if breach
72
+ end
73
+
74
+ # NB: a per-agent key PRESENT with nil (e.g. an imported pack carrying
75
+ # `"chat_rate_limit": null`) reads as OFF for that agent, not "inherit".
76
+ if (ceiling = positive(limits.key?(:agent_token_ceiling) ? limits[:agent_token_ceiling] : edge["agent_token_ceiling"]))
77
+ token_window = positive(edge["agent_token_window"]) || DEFAULT_TOKEN_WINDOW
78
+ unless resumed
79
+ spent = @ledger.count(TOKENS_KIND, state.profile.id.to_s, window: token_window)
80
+ if spent >= ceiling
81
+ return block(state, edge, category: :token_ceiling,
82
+ detail: "agent #{state.profile.id}: #{spent}/#{ceiling} tokens per #{token_window}s")
83
+ end
84
+ end
85
+
86
+ record_after = token_window
87
+ end
88
+
89
+ # WS2: calendar budgets. Entry — a HARD budget at/over the cap raises the
90
+ # typed error (never a customer-facing reply); the alert_at warning and
91
+ # the SOFT over-cap both warn once per window + inject a context note.
92
+ # A resumed turn (crash/pause replay) was already admitted: it is never
93
+ # refused twice — its spend still lands on the ledger below.
94
+ budget_on = budget_configured?(state)
95
+ budget_enforce(state) unless resumed
96
+
97
+ result = begin
98
+ nxt.call(state)
99
+ ensure
100
+ # A turn that FAILED after burning tokens still SPENT them: record the
101
+ # usage the state captured before the error propagates. The ask's usage
102
+ # lands on state.usage before any later stage (guardrail block, tool
103
+ # error, workflow schema) can fail the turn — a failed turn must count
104
+ # against the budget like a completed one (WS2).
105
+ record_usage(state, record_after) if record_after
106
+ record_budget_usage(state) if budget_on
107
+ end
108
+ result
109
+ end
110
+
111
+ private
112
+
113
+ # One KV get per turn (same order of cost as the guardrail's config read);
114
+ # no SettingsStore in the wiring -> per-agent limits only.
115
+ def platform_edge
116
+ (@settings&.get || {})["edge"] || {}
117
+ end
118
+
119
+ # Counts the ATTEMPT first, then compares — the standard fixed-window
120
+ # semantics (blocked attempts keep counting). A blank chat id is skipped:
121
+ # unrelated anonymous traffic must not share one bucket.
122
+ def check_chat_rate(state, limit, edge)
123
+ chat_id = (state.turn_context || {})[:chat_id].to_s
124
+ return nil if chat_id.empty?
125
+
126
+ window = positive(edge["chat_rate_window"]) || DEFAULT_CHAT_WINDOW
127
+ taken = @ledger.add(CHAT_KIND, chat_id, window: window)
128
+ return nil if taken <= limit
129
+
130
+ { category: :rate_limit, detail: "chat #{chat_id}: #{taken}/#{limit} turns per #{window}s" }
131
+ end
132
+
133
+ # The turn's real spend, accumulated on the agent's ledger. The engine's
134
+ # `total_tokens` is input + output and DELIBERATELY excludes the cached
135
+ # prefix (`Executor#usage_of` reports `cached_tokens`/`cache_creation_tokens`
136
+ # alongside it) — on a cached identity that prefix is ~95% of what the
137
+ # provider actually processed, so a ceiling reading only `total_tokens` is
138
+ # blind. Same billed-spend rule as `Evals::Runner#billed_tokens`.
139
+ # nil usage (workflow turn / provider without counts) records nothing.
140
+ def record_usage(state, window)
141
+ usage = state.usage || {}
142
+ tokens = usage[:total_tokens].to_i + usage[:cached_tokens].to_i +
143
+ usage[:cache_creation_tokens].to_i
144
+ return if tokens.zero?
145
+
146
+ @ledger.add(TOKENS_KIND, state.profile.id.to_s, window: window, by: tokens)
147
+ end
148
+
149
+ # Graceful halt: safe reply + audit metadata, short-circuit (no nxt).
150
+ # `detail` carries only ids/counters — never message content.
151
+ def block(state, edge, category:, detail:)
152
+ state.halt_response = Coercion.presence(edge["limit_response"]) || DEFAULT_RESPONSE
153
+ state.guardrail_block = {
154
+ category: category.to_s, source: "edge", action: "refuse", detail: detail
155
+ }
156
+ nil
157
+ end
158
+
159
+ def positive(value)
160
+ v = value.to_i
161
+ v.positive? ? v : nil
162
+ end
163
+
164
+ # --- WS2 calendar budgets ------------------------------------------
165
+
166
+ # -> truthy when a budget is configured AND the ledger is wired.
167
+ def budget_configured?(state)
168
+ budget = state.profile.respond_to?(:budget) ? state.profile.budget : nil
169
+ !budget.nil? && !@budget_ledger.nil?
170
+ end
171
+
172
+ # -> truthy (the budget hash) when budget checks ran. Raises BudgetExceeded
173
+ # on a HARD cap breach.
174
+ def budget_enforce(state, now: Time.now)
175
+ budget = state.profile.respond_to?(:budget) ? state.profile.budget : nil
176
+ return nil if budget.nil? || @budget_ledger.nil?
177
+
178
+ tenant = budget_tenant(state)
179
+ agent = state.profile.id.to_s
180
+ budget_windows(budget).each do |w|
181
+ spent = @budget_ledger.current(tenant: tenant, agent: agent, now: now)[w[:window]]
182
+ if spent >= w[:cap]
183
+ unless w[:soft]
184
+ raise Insika::BudgetExceeded.new(
185
+ window: w[:window],
186
+ retry_after: @budget_ledger.reset_in(w[:window], now: now)
187
+ )
188
+ end
189
+ warn_budget(state, tenant, agent, w, spent, now, level: "cap")
190
+ elsif spent >= w[:alert_at]
191
+ warn_budget(state, tenant, agent, w, spent, now, level: "alert_at")
192
+ end
193
+ end
194
+ budget
195
+ end
196
+
197
+ # The (tenant, agent) scope: the COMMAND's tenant (nil -> the BudgetLedger's
198
+ # "platform" cell) — never state.tenant, which falls back to the session id
199
+ # (a per-chat bucket is not a budget).
200
+ def budget_tenant(state)
201
+ command = state.respond_to?(:task) && state.task&.command
202
+ return nil unless command.is_a?(Hash)
203
+
204
+ meta = command["meta"] || command[:meta] || {}
205
+ meta["tenant"] || meta[:tenant]
206
+ end
207
+
208
+ # -> [{ window:, cap:, soft:, alert_at: }] — one entry per configured window
209
+ # (a 0/absent cap is off). absent `soft` = FALSE (hard): a limit that does
210
+ # not limit is decoration; the alert_at warning is the soft half.
211
+ def budget_windows(budget)
212
+ alert_at = budget["alert_at"].to_f
213
+ alert_at = 0.8 if alert_at <= 0 || alert_at >= 1
214
+ soft = budget["soft"] == true
215
+ %i[daily monthly].filter_map do |window|
216
+ cap = budget[window.to_s].to_i
217
+ cap.positive? ? { window: window, cap: cap, soft: soft,
218
+ alert_at: (cap * alert_at).floor } : nil
219
+ end
220
+ end
221
+
222
+ # The warning: a note in the context (the model sees it, the customer's
223
+ # transcript does not) + the budget_warning event — each LEVEL once per
224
+ # (window) cell: the `alert_at` crossing and the real soft-cap crossing are
225
+ # separate markers, so the cap event is never swallowed by the 80% one that
226
+ # fired earlier (WS2).
227
+ def warn_budget(state, tenant, agent, w, spent, now, level:)
228
+ inject_budget_note(state,
229
+ "[budget: agent '#{agent}' is at #{spent}/#{w[:cap]} tokens this " \
230
+ "#{w[:window]} window — keep this turn cheap]")
231
+ return if @budget_ledger.mark_alert(tenant: tenant, agent: agent, window: w[:window],
232
+ level: level, now: now)
233
+
234
+ @event_stream&.emit(Insika::Event.new(
235
+ type: :budget_warning,
236
+ data: { agent: agent, tenant: tenant, window: w[:window],
237
+ spent: spent, cap: w[:cap], level: level },
238
+ meta: { task_id: state.task&.id, session_id: state.task&.session_id,
239
+ at: Time.now.utc.iso8601 }
240
+ ))
241
+ end
242
+
243
+ # Appends the note to the assembled system prompt: the real Data package is
244
+ # immutable (with), the specs' minimal Struct is mutable — both duck-typed.
245
+ def inject_budget_note(state, note)
246
+ ctx = state.context
247
+ return if ctx.nil?
248
+
249
+ if ctx.respond_to?(:with)
250
+ state.context = ctx.with(system: "#{ctx.system}\n\n#{note}")
251
+ elsif ctx.respond_to?(:system=)
252
+ ctx.system = "#{ctx.system}\n\n#{note}"
253
+ end
254
+ end
255
+
256
+ # The turn's REAL billed spend (input + output + cached + cache_creation —
257
+ # the A4 rule) on the calendar windows.
258
+ def record_budget_usage(state, now: Time.now)
259
+ usage = state.usage || {}
260
+ tokens = usage[:total_tokens].to_i + usage[:cached_tokens].to_i +
261
+ usage[:cache_creation_tokens].to_i
262
+ return if tokens.zero?
263
+
264
+ @budget_ledger.add(tenant: budget_tenant(state), agent: state.profile.id.to_s,
265
+ by: tokens, now: now)
266
+ end
267
+ end
268
+ end