insika 0.0.1 → 0.1.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 (260) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +351 -0
  6. data/docs/AGENTS.md +494 -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 +100 -0
  11. data/docs/DEPLOY.md +334 -0
  12. data/docs/EMBEDDING.md +194 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +231 -0
  15. data/docs/OBSERVABILITY.md +365 -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 +362 -0
  22. data/docs/SKILLS.md +98 -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 +188 -0
  34. data/lib/insika/allowlist.rb +28 -0
  35. data/lib/insika/baseline_store.rb +74 -0
  36. data/lib/insika/capability/resolved_tool.rb +34 -0
  37. data/lib/insika/capability_registry.rb +112 -0
  38. data/lib/insika/channel_delivery.rb +150 -0
  39. data/lib/insika/channel_registry.rb +30 -0
  40. data/lib/insika/channels/relay.rb +178 -0
  41. data/lib/insika/channels/web/widget.js +283 -0
  42. data/lib/insika/channels/web.rb +211 -0
  43. data/lib/insika/chat_builder.rb +254 -0
  44. data/lib/insika/checkpoint.rb +13 -0
  45. data/lib/insika/checkpoint_store.rb +153 -0
  46. data/lib/insika/coercion.rb +50 -0
  47. data/lib/insika/command.rb +32 -0
  48. data/lib/insika/command_bus.rb +39 -0
  49. data/lib/insika/commands/agent_payload.rb +41 -0
  50. data/lib/insika/commands/approve_action.rb +46 -0
  51. data/lib/insika/commands/cancel_task.rb +33 -0
  52. data/lib/insika/commands/create_agent.rb +54 -0
  53. data/lib/insika/commands/create_session.rb +67 -0
  54. data/lib/insika/commands/delete_agent.rb +33 -0
  55. data/lib/insika/commands/delete_agent_file.rb +50 -0
  56. data/lib/insika/commands/delete_data_tool.rb +33 -0
  57. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  58. data/lib/insika/commands/delete_mcp.rb +30 -0
  59. data/lib/insika/commands/delete_system_file.rb +29 -0
  60. data/lib/insika/commands/gate_refinement.rb +245 -0
  61. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  62. data/lib/insika/commands/import_tools.rb +81 -0
  63. data/lib/insika/commands/memory_add_note.rb +32 -0
  64. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  65. data/lib/insika/commands/memory_put_fact.rb +35 -0
  66. data/lib/insika/commands/pause_task.rb +29 -0
  67. data/lib/insika/commands/resolve_refinement.rb +126 -0
  68. data/lib/insika/commands/restore_agent_file.rb +36 -0
  69. data/lib/insika/commands/restore_data_tool.rb +34 -0
  70. data/lib/insika/commands/restore_system_file.rb +31 -0
  71. data/lib/insika/commands/resume_task.rb +85 -0
  72. data/lib/insika/commands/run_refinement.rb +133 -0
  73. data/lib/insika/commands/send_message.rb +150 -0
  74. data/lib/insika/commands/set_agent_tools.rb +39 -0
  75. data/lib/insika/commands/set_skill_agents.rb +71 -0
  76. data/lib/insika/commands/trigger_workflow.rb +80 -0
  77. data/lib/insika/commands/update_agent.rb +49 -0
  78. data/lib/insika/commands/update_settings.rb +33 -0
  79. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  80. data/lib/insika/commands/upsert_mcp.rb +32 -0
  81. data/lib/insika/commands/write_agent_file.rb +57 -0
  82. data/lib/insika/commands/write_data_tool.rb +43 -0
  83. data/lib/insika/commands/write_golden.rb +58 -0
  84. data/lib/insika/commands/write_skill.rb +50 -0
  85. data/lib/insika/commands/write_system_file.rb +31 -0
  86. data/lib/insika/config_store.rb +85 -0
  87. data/lib/insika/context/builder.rb +166 -0
  88. data/lib/insika/context/catalog_provider.rb +23 -0
  89. data/lib/insika/context/fragment.rb +19 -0
  90. data/lib/insika/context/priority.rb +29 -0
  91. data/lib/insika/context/provider.rb +19 -0
  92. data/lib/insika/context/providers/memory.rb +60 -0
  93. data/lib/insika/context/providers/prompt.rb +105 -0
  94. data/lib/insika/context/providers/request.rb +32 -0
  95. data/lib/insika/context/providers/session.rb +108 -0
  96. data/lib/insika/context/providers/skill.rb +20 -0
  97. data/lib/insika/context/providers/tool_search.rb +20 -0
  98. data/lib/insika/delegation_store.rb +153 -0
  99. data/lib/insika/doctor.rb +294 -0
  100. data/lib/insika/dsl/definition.rb +55 -0
  101. data/lib/insika/dsl/runtime.rb +379 -0
  102. data/lib/insika/dsl/server_boot.rb +97 -0
  103. data/lib/insika/dsl/system.rb +93 -0
  104. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  105. data/lib/insika/dsl.rb +307 -0
  106. data/lib/insika/edge_limiter.rb +130 -0
  107. data/lib/insika/egress_guard.rb +75 -0
  108. data/lib/insika/env_schema.rb +246 -0
  109. data/lib/insika/errors.rb +145 -0
  110. data/lib/insika/evals/assertions.rb +247 -0
  111. data/lib/insika/evals/baseline.rb +69 -0
  112. data/lib/insika/evals/golden.rb +172 -0
  113. data/lib/insika/evals/judge.rb +225 -0
  114. data/lib/insika/evals/pairwise.rb +178 -0
  115. data/lib/insika/evals/report.rb +115 -0
  116. data/lib/insika/evals/runner.rb +141 -0
  117. data/lib/insika/evals/transport.rb +178 -0
  118. data/lib/insika/event.rb +18 -0
  119. data/lib/insika/event_stream.rb +114 -0
  120. data/lib/insika/executor.rb +1680 -0
  121. data/lib/insika/frontmatter.rb +42 -0
  122. data/lib/insika/golden_store.rb +145 -0
  123. data/lib/insika/hooks.rb +48 -0
  124. data/lib/insika/http_client.rb +63 -0
  125. data/lib/insika/inbound_log.rb +84 -0
  126. data/lib/insika/llm_configurator.rb +99 -0
  127. data/lib/insika/llm_provider_store.rb +83 -0
  128. data/lib/insika/mcp_http_client.rb +67 -0
  129. data/lib/insika/mcp_store.rb +115 -0
  130. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  131. data/lib/insika/memory_store.rb +93 -0
  132. data/lib/insika/message_origin.rb +76 -0
  133. data/lib/insika/middleware.rb +36 -0
  134. data/lib/insika/model_policy.rb +52 -0
  135. data/lib/insika/model_resolver.rb +176 -0
  136. data/lib/insika/model_selection.rb +114 -0
  137. data/lib/insika/onboarding.rb +208 -0
  138. data/lib/insika/outbox_store.rb +166 -0
  139. data/lib/insika/overlay_tool_registry.rb +103 -0
  140. data/lib/insika/pack.rb +102 -0
  141. data/lib/insika/pack_importer.rb +121 -0
  142. data/lib/insika/pending_action_store.rb +120 -0
  143. data/lib/insika/plugin/loader.rb +356 -0
  144. data/lib/insika/plugin.rb +35 -0
  145. data/lib/insika/policy/engine.rb +83 -0
  146. data/lib/insika/policy/policy.rb +120 -0
  147. data/lib/insika/policy_registry.rb +23 -0
  148. data/lib/insika/profile_source.rb +137 -0
  149. data/lib/insika/prompt_catalog.rb +61 -0
  150. data/lib/insika/queue_policy.rb +167 -0
  151. data/lib/insika/recovery.rb +127 -0
  152. data/lib/insika/refinement/candidate.rb +159 -0
  153. data/lib/insika/refinement/evidence_collector.rb +371 -0
  154. data/lib/insika/refinement/gate.rb +234 -0
  155. data/lib/insika/refinement/panel.rb +222 -0
  156. data/lib/insika/refinement/proposer.rb +262 -0
  157. data/lib/insika/refinement_store.rb +295 -0
  158. data/lib/insika/registry.rb +59 -0
  159. data/lib/insika/safety/config.rb +109 -0
  160. data/lib/insika/safety/detectors.rb +176 -0
  161. data/lib/insika/safety/factory.rb +102 -0
  162. data/lib/insika/safety/input_guardrail.rb +87 -0
  163. data/lib/insika/safety/moderator.rb +86 -0
  164. data/lib/insika/safety/output_filter.rb +79 -0
  165. data/lib/insika/safety/output_validator.rb +101 -0
  166. data/lib/insika/safety/safe_responses.rb +47 -0
  167. data/lib/insika/sandbox/boundary.rb +93 -0
  168. data/lib/insika/sandbox/docker.rb +74 -0
  169. data/lib/insika/sandbox/local.rb +33 -0
  170. data/lib/insika/sandbox/runner.rb +80 -0
  171. data/lib/insika/sandbox.rb +85 -0
  172. data/lib/insika/schema_guard.rb +147 -0
  173. data/lib/insika/secret_masking.rb +34 -0
  174. data/lib/insika/server/a2a/agent_card.rb +27 -0
  175. data/lib/insika/server/a2a/app.rb +112 -0
  176. data/lib/insika/server/a2a/client.rb +101 -0
  177. data/lib/insika/server/a2a/errors.rb +32 -0
  178. data/lib/insika/server/a2a/http.rb +42 -0
  179. data/lib/insika/server/a2a/message.rb +27 -0
  180. data/lib/insika/server/a2a/protocol.rb +45 -0
  181. data/lib/insika/server/a2a/remotes.rb +25 -0
  182. data/lib/insika/server/a2a/task_projection.rb +40 -0
  183. data/lib/insika/server/admin_auth.rb +29 -0
  184. data/lib/insika/server/app.rb +850 -0
  185. data/lib/insika/server/boot.rb +119 -0
  186. data/lib/insika/server/rack_app.rb +110 -0
  187. data/lib/insika/server/responses.rb +155 -0
  188. data/lib/insika/server/sse_body.rb +96 -0
  189. data/lib/insika/session_actor.rb +162 -0
  190. data/lib/insika/session_store.rb +143 -0
  191. data/lib/insika/settings_store.rb +154 -0
  192. data/lib/insika/shutdown.rb +125 -0
  193. data/lib/insika/skill_catalog.rb +113 -0
  194. data/lib/insika/skill_store.rb +79 -0
  195. data/lib/insika/steer_injector.rb +110 -0
  196. data/lib/insika/store.rb +52 -0
  197. data/lib/insika/stores/memory.rb +123 -0
  198. data/lib/insika/stores/sqlite.rb +183 -0
  199. data/lib/insika/studio/app.rb +1571 -0
  200. data/lib/insika/studio/assets/dist/application.css +1 -0
  201. data/lib/insika/studio/assets/dist/application.js +69 -0
  202. data/lib/insika/studio/forms.rb +340 -0
  203. data/lib/insika/studio/nav_icons.rb +31 -0
  204. data/lib/insika/studio/views/_message.erb +44 -0
  205. data/lib/insika/studio/views/agent_detail.erb +285 -0
  206. data/lib/insika/studio/views/agents.erb +63 -0
  207. data/lib/insika/studio/views/approvals.erb +41 -0
  208. data/lib/insika/studio/views/chats.erb +34 -0
  209. data/lib/insika/studio/views/evals.erb +83 -0
  210. data/lib/insika/studio/views/home.erb +72 -0
  211. data/lib/insika/studio/views/layout.erb +94 -0
  212. data/lib/insika/studio/views/login.erb +17 -0
  213. data/lib/insika/studio/views/mcp.erb +91 -0
  214. data/lib/insika/studio/views/not_found.erb +5 -0
  215. data/lib/insika/studio/views/playground.erb +47 -0
  216. data/lib/insika/studio/views/refinement.erb +234 -0
  217. data/lib/insika/studio/views/session.erb +62 -0
  218. data/lib/insika/studio/views/settings.erb +173 -0
  219. data/lib/insika/studio/views/skills.erb +86 -0
  220. data/lib/insika/studio/views/system_files.erb +65 -0
  221. data/lib/insika/studio/views/task.erb +105 -0
  222. data/lib/insika/studio/views/tasks.erb +33 -0
  223. data/lib/insika/studio/views/tool_edit.erb +107 -0
  224. data/lib/insika/studio/views/tools.erb +89 -0
  225. data/lib/insika/subagent_graph.rb +96 -0
  226. data/lib/insika/system_file_store.rb +96 -0
  227. data/lib/insika/task_actor.rb +128 -0
  228. data/lib/insika/task_store.rb +250 -0
  229. data/lib/insika/telemetry/pricing.rb +104 -0
  230. data/lib/insika/telemetry/recorder.rb +228 -0
  231. data/lib/insika/telemetry.rb +127 -0
  232. data/lib/insika/testing/store_contract.rb +270 -0
  233. data/lib/insika/token_estimator.rb +16 -0
  234. data/lib/insika/tool_assembly.rb +140 -0
  235. data/lib/insika/tool_catalog.rb +89 -0
  236. data/lib/insika/tool_definition.rb +518 -0
  237. data/lib/insika/tool_envelope.rb +140 -0
  238. data/lib/insika/tool_manifest.rb +218 -0
  239. data/lib/insika/tool_registry.rb +21 -0
  240. data/lib/insika/tool_store.rb +135 -0
  241. data/lib/insika/tool_trace_store.rb +92 -0
  242. data/lib/insika/tools/a2a_remote.rb +48 -0
  243. data/lib/insika/tools/agent_enum.rb +68 -0
  244. data/lib/insika/tools/concurrency.rb +54 -0
  245. data/lib/insika/tools/data_defined_tool.rb +220 -0
  246. data/lib/insika/tools/load_skill.rb +41 -0
  247. data/lib/insika/tools/remember.rb +53 -0
  248. data/lib/insika/tools/subagent.rb +75 -0
  249. data/lib/insika/tools/subagents.rb +77 -0
  250. data/lib/insika/tools/tool_search.rb +94 -0
  251. data/lib/insika/turn_output.rb +139 -0
  252. data/lib/insika/turn_state.rb +158 -0
  253. data/lib/insika/turn_timing.rb +56 -0
  254. data/lib/insika/usage_ledger.rb +47 -0
  255. data/lib/insika/version.rb +3 -1
  256. data/lib/insika/wiring/graph.rb +198 -0
  257. data/lib/insika/workflow.rb +185 -0
  258. data/lib/insika/workflow_registry.rb +33 -0
  259. data/lib/insika.rb +203 -4
  260. metadata +395 -8
data/lib/insika/dsl.rb ADDED
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pack"
4
+
5
+ module Insika
6
+ # Public Ruby DSL (item 36 / §13.3) — the OSS "business card":
7
+ #
8
+ # agent = Insika.agent("assistant") do
9
+ # model "deepseek-chat"
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 §4.1). `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 (NF2).
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
+ # --- delegation ------------------------------------------------------
177
+ # subagents "security", "performance" → the child agents this one MAY
178
+ # spawn (RFC-0010). CAPACITY field: opt-in, never inherited, and the ids
179
+ # must be agents of the same system (`Insika.system { … }`) or already in
180
+ # the store. Present ⇒ the engine wires `spawn_subagent`/`spawn_subagents`.
181
+ def subagents(*ids)
182
+ @config[:subagents] = ids.flatten.map(&:to_s)
183
+ end
184
+
185
+ # --- knobs -----------------------------------------------------------
186
+ def memory(on = true) = @config[:memory] = on
187
+
188
+ # Content-safety guardrails (RFC-0009) — opt-in and configurable per agent.
189
+ # Pure config-over-code: the hash is stored on the profile and consumed by
190
+ # Safety::Config.from_profile. Merges, so repeated calls accumulate.
191
+ # guardrails input: true, output: true, strictness: "medium",
192
+ # moderator: "deepseek/deepseek-chat",
193
+ # responses: { "injection" => "I can't help with that." }
194
+ def guardrails(hash) = (@config[:guardrails] ||= {}).merge!(hash.transform_keys(&:to_s))
195
+
196
+ # Refinement (RFC-0013) — how the agent's own instruction files may be
197
+ # improved from real traffic. Same config-over-code shape as `guardrails`;
198
+ # omitting it entirely leaves the agent report-only (phase A writes nothing).
199
+ # refine mode: "propose", window: { last_sessions: 200 }, files: %w[TOOLS.md],
200
+ # proposers: ["deepseek/deepseek-chat", "gpt-5-mini"],
201
+ # budget: { tokens: 200_000 }
202
+ def refine(hash) = (@config[:refinement] ||= {}).merge!(hash.transform_keys(&:to_s))
203
+
204
+ # Facts about THIS deployment that are not tools (RFC-0014 §3.5), so an eval
205
+ # case can declare what it needs and be skipped where it is absent instead of
206
+ # failing for the wrong reason.
207
+ # declares "promotions", "human_handoff"
208
+ def declares(*names)
209
+ (@config[:capabilities_declared] ||= []).concat(names.flatten.map(&:to_s))
210
+ end
211
+
212
+ # Which internal channels may cross to the CUSTOMER. Both off by default: the
213
+ # answer is the answer, and the provider's reasoning (`thinking`) or the model
214
+ # narrating its tool loop (`intermediate`) is for the Studio and the trace.
215
+ # Each opted-in channel gets its OWN frame type at `/v1/responses` — never the
216
+ # answer's — so a consumer that only reads the answer is unaffected either way.
217
+ # edge_stream thinking: true, intermediate: false
218
+ def edge_stream(hash) = (@config[:edge_stream] ||= {}).merge!(hash.transform_keys(&:to_s))
219
+
220
+ # LLM generation params (v2, §10). `param :temperature, 0.2` or `params(...)`.
221
+ def param(key, value) = (@config[:params] ||= {})[key.to_sym] = value
222
+ def params(hash) = (@config[:params] ||= {}).merge!(hash.transform_keys(&:to_sym))
223
+ def temperature(value) = param(:temperature, value)
224
+ def max_tokens(value) = param(:max_tokens, value)
225
+
226
+ # Per-agent limits (timeouts/budgets). `limit :turn_timeout, 120` or `limits(...)`.
227
+ def limit(key, value) = (@config[:limits] ||= {})[key.to_sym] = value
228
+ def limits(hash) = (@config[:limits] ||= {}).merge!(hash.transform_keys(&:to_sym))
229
+
230
+ def policies(*names) = @config[:policies] = names.flatten.map(&:to_sym)
231
+ def metadata(hash) = (@config[:metadata] ||= {}).merge!(hash.transform_keys(&:to_s))
232
+
233
+ # --- runtime (LLM provider) config — NOT part of the pack ------------
234
+ # Configures RubyLLM at chat/serve time. Defaults: provider = the agent's
235
+ # provider; key = ENV["<PROVIDER>_API_KEY"].
236
+ def api_key(value) = @runtime[:api_key] = value.to_s
237
+ def api_base(value) = @runtime[:api_base] = value.to_s
238
+
239
+ # The generated portable artifact — the heart of "generates the data".
240
+ def to_pack
241
+ Insika::Pack.from_h(
242
+ config: @config.merge(id: @id),
243
+ files: @files, skills: @skills, tools: @tools
244
+ )
245
+ end
246
+
247
+ private
248
+
249
+ # Ensure the skill body is a valid SKILL.md (YAML frontmatter with `name`),
250
+ # which is what SkillCatalog parses. Raw content with frontmatter passes
251
+ # through untouched; a bare body / structured args get wrapped.
252
+ def normalize_skill(name, content, description, instructions)
253
+ return content.to_s if content.is_a?(String) && content.lstrip.start_with?("---")
254
+
255
+ body = (instructions || content).to_s
256
+ desc = (description || first_line(body) || name).to_s
257
+ <<~SKILL
258
+ ---
259
+ name: #{name}
260
+ description: #{desc}
261
+ ---
262
+
263
+ #{body}
264
+ SKILL
265
+ end
266
+
267
+ def first_line(text) = text.to_s.strip.lines.first&.strip
268
+ end
269
+ end
270
+
271
+ module_function
272
+
273
+ # Top-level entry point (see Insika::DSL). Returns a Insika::DSL::Definition.
274
+ def agent(id, &block)
275
+ DSL.agent(id, &block)
276
+ end
277
+
278
+ # Several agents in one runtime — the shape every multi-agent pattern needs
279
+ # (delegation, fan-out/fan-in, routing). Returns a Insika::DSL::System.
280
+ def system(&block)
281
+ DSL.system(&block)
282
+ end
283
+
284
+ # RFC-0017 A1 — the front door for MOUNTING Insika into an app you already
285
+ # have. Same block as `Insika.system`, one added obligation: the caller names
286
+ # the store, so the graph stops discovering it from `INSIKA_DB` and two graphs
287
+ # in one process can no longer read each other's sessions.
288
+ #
289
+ # INSIKA = Insika.embed(backend: Insika::Stores::SQLite.new(path: "storage/insika.sqlite3")) do
290
+ # agent "support" do
291
+ # model "deepseek-chat"
292
+ # instructions "…"
293
+ # end
294
+ # end
295
+ # # config/routes.rb — mount the /v1 transport as a value:
296
+ # mount Insika::Server.rack_app(INSIKA, token: ENV.fetch("INSIKA_TOKEN")), at: "/ai"
297
+ #
298
+ # It is a thin front door over the SAME assembly `Insika.system` uses — there is
299
+ # one pipeline (RFC-0001), and the parity spec holds it to that. What an embedded
300
+ # graph owns, and what it still shares with the process, is docs/EMBEDDING.md.
301
+ def embed(backend:, &block)
302
+ DSL.embed(backend: backend, &block)
303
+ end
304
+ end
305
+
306
+ require_relative "dsl/definition"
307
+ require_relative "dsl/system"
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "middleware"
4
+ require_relative "coercion"
5
+
6
+ module Insika
7
+ # The production edge (item 33 / §12 G7): 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
+ #
17
+ # Config resolution, per turn (configuration over convention):
18
+ # profile.limits[:chat_rate_limit / :agent_token_ceiling] — per-agent override
19
+ # settings["edge"] — platform default
20
+ # A per-agent 0 explicitly disables a platform default for that agent.
21
+ #
22
+ # On breach it uses the graceful-halt contract (RFC-0009 §3.1): halt_response
23
+ # (the safe reply) + guardrail_block (audit -> :guardrail_blocked) and does NOT
24
+ # call `nxt` — the turn completes with ZERO LLM calls. It sits BEFORE the
25
+ # InputGuardrail in the stack so a flood can't spend the LLM moderator either.
26
+ class EdgeLimiter < Middleware
27
+ CHAT_KIND = "chat"
28
+ TOKENS_KIND = "tokens"
29
+
30
+ DEFAULT_CHAT_WINDOW = 60 # seconds
31
+ DEFAULT_TOKEN_WINDOW = 86_400 # seconds (daily ceiling)
32
+
33
+ # Neutral fallback, same contract as Safety::SafeResponses (pt-BR — the
34
+ # pilot's language; override via settings edge.limit_response).
35
+ DEFAULT_RESPONSE = "Estou recebendo muitas mensagens agora. Aguarde um " \
36
+ "momento e tente novamente, por favor."
37
+
38
+ def initialize(ledger:, settings_store: nil)
39
+ @ledger = ledger
40
+ @settings = settings_store
41
+ end
42
+
43
+ def call(state, &nxt)
44
+ edge = platform_edge
45
+ limits = state.profile.limits || {}
46
+ # A resume (crash/pause recovery) re-enters the pipeline for a turn that was
47
+ # ALREADY admitted: re-counting it would swallow a legitimate message with
48
+ # the rate-limit reply exactly when the window is saturated. Entry checks
49
+ # are skipped; the turn's usage still lands on the ledger below.
50
+ resumed = state.resumed
51
+
52
+ if !resumed && (limit = positive(limits.key?(:chat_rate_limit) ? limits[:chat_rate_limit] : edge["chat_rate_limit"]))
53
+ breach = check_chat_rate(state, limit, edge)
54
+ return block(state, edge, **breach) if breach
55
+ end
56
+
57
+ # NB: a per-agent key PRESENT with nil (e.g. an imported pack carrying
58
+ # `"chat_rate_limit": null`) reads as OFF for that agent, not "inherit".
59
+ if (ceiling = positive(limits.key?(:agent_token_ceiling) ? limits[:agent_token_ceiling] : edge["agent_token_ceiling"]))
60
+ token_window = positive(edge["agent_token_window"]) || DEFAULT_TOKEN_WINDOW
61
+ unless resumed
62
+ spent = @ledger.count(TOKENS_KIND, state.profile.id.to_s, window: token_window)
63
+ if spent >= ceiling
64
+ return block(state, edge, category: :token_ceiling,
65
+ detail: "agent #{state.profile.id}: #{spent}/#{ceiling} tokens per #{token_window}s")
66
+ end
67
+ end
68
+
69
+ record_after = token_window
70
+ end
71
+
72
+ result = nxt.call(state)
73
+ record_usage(state, record_after) if record_after
74
+ result
75
+ end
76
+
77
+ private
78
+
79
+ # One KV get per turn (same order of cost as the guardrail's config read);
80
+ # no SettingsStore in the wiring -> per-agent limits only.
81
+ def platform_edge
82
+ (@settings&.get || {})["edge"] || {}
83
+ end
84
+
85
+ # Counts the ATTEMPT first, then compares — the standard fixed-window
86
+ # semantics (blocked attempts keep counting). A blank chat id is skipped:
87
+ # unrelated anonymous traffic must not share one bucket.
88
+ def check_chat_rate(state, limit, edge)
89
+ chat_id = (state.turn_context || {})[:chat_id].to_s
90
+ return nil if chat_id.empty?
91
+
92
+ window = positive(edge["chat_rate_window"]) || DEFAULT_CHAT_WINDOW
93
+ taken = @ledger.add(CHAT_KIND, chat_id, window: window)
94
+ return nil if taken <= limit
95
+
96
+ { category: :rate_limit, detail: "chat #{chat_id}: #{taken}/#{limit} turns per #{window}s" }
97
+ end
98
+
99
+ # The turn's real spend, accumulated on the agent's ledger. The engine's
100
+ # `total_tokens` is input + output and DELIBERATELY excludes the cached
101
+ # prefix (`Executor#usage_of` reports `cached_tokens`/`cache_creation_tokens`
102
+ # alongside it) — on a cached identity that prefix is ~95% of what the
103
+ # provider actually processed, so a ceiling reading only `total_tokens` is
104
+ # blind (RFC-0016 A4). Same billed-spend rule as `Evals::Runner#billed_tokens`.
105
+ # nil usage (workflow turn / provider without counts) records nothing.
106
+ def record_usage(state, window)
107
+ usage = state.usage || {}
108
+ tokens = usage[:total_tokens].to_i + usage[:cached_tokens].to_i +
109
+ usage[:cache_creation_tokens].to_i
110
+ return if tokens.zero?
111
+
112
+ @ledger.add(TOKENS_KIND, state.profile.id.to_s, window: window, by: tokens)
113
+ end
114
+
115
+ # Graceful halt: safe reply + audit metadata, short-circuit (no nxt).
116
+ # `detail` carries only ids/counters — never message content.
117
+ def block(state, edge, category:, detail:)
118
+ state.halt_response = Coercion.presence(edge["limit_response"]) || DEFAULT_RESPONSE
119
+ state.guardrail_block = {
120
+ category: category.to_s, source: "edge", action: "refuse", detail: detail
121
+ }
122
+ nil
123
+ end
124
+
125
+ def positive(value)
126
+ v = value.to_i
127
+ v.positive? ? v : nil
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "ipaddr"
5
+ require "resolv"
6
+
7
+ module Insika
8
+ # EGRESS guard for data-tools (SSRF). A data-tool makes a server-side HTTP
9
+ # request with a URL coming from UI-editable config — without a guard, it's an
10
+ # SSRF vector (hitting cloud metadata, internal services, localhost). Rules
11
+ # (spec NF2):
12
+ # - https only by default (http requires explicit opt-in);
13
+ # - host required;
14
+ # - optional host allowlist (when present, only it passes);
15
+ # - resolves the host and BLOCKS if ANY address falls into a private/
16
+ # loopback/link-local/metadata network (defense against DNS rebinding);
17
+ # - `allow_private:` (opt-in) ALLOWS the private target — to reach a trusted
18
+ # INTERNAL API (NF4: the consumer's /api/internal/* comes in via an
19
+ # allowlist). Dangerous without `host_allowlist`: PIN it to a known host.
20
+ # Default false = strict guard.
21
+ #
22
+ # `violation(url, ...)` returns nil (ok) or a String with the reason — the
23
+ # DataDefinedTool turns the reason into `{ error: }` to the model (never raises).
24
+ module EgressGuard
25
+ BLOCKED = [
26
+ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
27
+ "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16",
28
+ "198.18.0.0/15", "::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96"
29
+ ].map { |c| IPAddr.new(c) }.freeze
30
+
31
+ module_function
32
+
33
+ # -> nil (allowed) | String (block reason).
34
+ def violation(url, allow_http: false, host_allowlist: nil, allow_private: false)
35
+ uri = begin
36
+ URI.parse(url.to_s)
37
+ rescue URI::InvalidURIError
38
+ return "invalid URL"
39
+ end
40
+
41
+ return "unsupported scheme" unless %w[http https].include?(uri.scheme)
42
+ return "http not allowed (use https)" if uri.scheme == "http" && !allow_http
43
+
44
+ host = uri.host
45
+ return "missing host" if host.nil? || host.empty?
46
+ return "host not in allowlist" if host_allowlist && !host_allowlist.include?(host)
47
+
48
+ addrs = resolve(host)
49
+ return "host did not resolve" if addrs.empty?
50
+ # allow_private skips the private-network block (trusted internal API,
51
+ # NF4). Without it, a private/loopback/metadata target is always blocked.
52
+ return "private-network destination blocked" if !allow_private && addrs.any? { |ip| blocked?(ip) }
53
+
54
+ nil
55
+ end
56
+
57
+ # Literal host (IP) -> itself; hostname -> resolve via DNS. -> [IPAddr].
58
+ def resolve(host)
59
+ literal = ip_or_nil(host.delete_prefix("[").delete_suffix("]"))
60
+ return [literal] if literal
61
+
62
+ Resolv.getaddresses(host).filter_map { |a| ip_or_nil(a) }
63
+ rescue Resolv::ResolvError, SocketError
64
+ []
65
+ end
66
+
67
+ def blocked?(ip) = BLOCKED.any? { |net| net.include?(ip) }
68
+
69
+ def ip_or_nil(str)
70
+ IPAddr.new(str)
71
+ rescue IPAddr::InvalidAddressError
72
+ nil
73
+ end
74
+ end
75
+ end