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
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "json"
5
+ require "erb"
6
+
7
+ module Insika
8
+ module Tools
9
+ # DATA-DEFINED tool: one class, N instances parameterized by a
10
+ # ToolDefinition (the same pattern as A2ARemote). It makes an HTTP call described
11
+ # in config — no Ruby code per tool. Since it inherits RubyLLM::Tool (pulls in the gem),
12
+ # it is NOT required in lib/insika.rb; the overlay loads it lazily at registration
13
+ # (Step B). Phase 5, Step A.
14
+ #
15
+ # Contract preserved by duck-typing: it overrides name/description/parameters/
16
+ # execute; RubyLLM's params_schema derives from #parameters automatically.
17
+ # execute NEVER raises — an error (missing param, blocked egress, HTTP, parse)
18
+ # becomes `{ error: }` to the model, like the other tools.
19
+ class DataDefinedTool < RubyLLM::Tool
20
+ def initialize(definition:, http:, egress: Insika::EgressGuard, egress_options: {},
21
+ event_stream: nil, turn_context: {})
22
+ @definition = definition
23
+ @http = http
24
+ @egress = egress
25
+ @egress_options = egress_options
26
+ @event_stream = event_stream
27
+ @turn_context = symbolize_ctx(turn_context)
28
+ super()
29
+ end
30
+
31
+ # Turn context (Phase 6/D2/G3): the registry tool does NOT receive TurnState,
32
+ # so the Executor DEPOSITS the turn ids here, per-turn
33
+ # (chat/agent/tenant/store). They resolve {{ctx.*}} — SEPARATE from the model's
34
+ # {{param}} — to emit X-Chat-Id/X-Store-Id/X-Agent-Id. They come from the TURN, never from
35
+ # the model (R2). Reader for testing; the writer is the Executor's injection point.
36
+ attr_reader :turn_context
37
+
38
+ def turn_context=(ctx)
39
+ @turn_context = symbolize_ctx(ctx)
40
+ end
41
+
42
+ # name/description/parameters per INSTANCE (otherwise the model would see the name
43
+ # derived from the class for every data-tool).
44
+ def name = @definition.name
45
+ def description = @definition.description
46
+
47
+ # FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
48
+ # the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
49
+ # params_schema; parameters is just a fallback). Provider-agnostic (Phase 7/F6) and
50
+ # the only form that expresses nesting (object/array/enum). Phase 7, Step A.
51
+ def params_schema = @definition.parameters
52
+
53
+ # FLAT top-level view for discovery (tool_search calls #parameters on the resolved
54
+ # tool). The real nested schema goes through #params_schema above.
55
+ def parameters
56
+ @parameters ||= @definition.top_level_params.each_with_object({}) do |p, acc|
57
+ sym = p[:name].to_sym
58
+ acc[sym] = RubyLLM::Parameter.new(sym, type: p[:type], desc: p[:description], required: p[:required])
59
+ end
60
+ end
61
+
62
+ # The args are checked against the tool's own JSON Schema BEFORE the request is
63
+ # built: a call the schema does not allow becomes `{ error: }` the model can act
64
+ # on, instead of a wrongly-shaped request that a backend answers 200 to.
65
+ def execute(**kwargs)
66
+ if (bad = Insika::SchemaGuard.violation(@definition.parameters, kwargs))
67
+ return { error: bad }
68
+ end
69
+
70
+ req = build_request(kwargs)
71
+ reason = @egress.violation(req[:url], **@egress_options)
72
+ return { error: "destination blocked: #{reason}" } if reason
73
+
74
+ result = @http.request(**req)
75
+ emit(result[:status])
76
+ payload = extract(result)
77
+ # The RESPONSE says the turn is over (`halt_when`): the backend already
78
+ # answered the customer, so letting the model comment would deliver the
79
+ # message twice. RubyLLM's Tool::Halt ends its loop right here — no second
80
+ # provider call, and the decision is the engine's, not a request in a prompt.
81
+ # Only on a 2xx: an error body that happens to carry the value is a failure,
82
+ # and a failure must reach the model.
83
+ if http_ok?(result) && @definition.halt?(result[:body])
84
+ # `say` (optional) travels WITH the halt so the Executor can publish it when
85
+ # the model wrote no lead-in. Wrapped only when there is one, so every tool
86
+ # that declares no `say` keeps producing exactly the payload it always did.
87
+ say = @definition.halt_say(result[:body])
88
+ return RubyLLM::Tool::Halt.new(say ? Insika::ToolDefinition.wrap_halt(payload, say) : payload)
89
+ end
90
+
91
+ payload
92
+ rescue StandardError => e
93
+ { error: "HTTP call failed: #{e.message}" }
94
+ end
95
+
96
+ private
97
+
98
+ # Interpolates the definition's templates with the model's args, escaping by
99
+ # context: url/query -> percent-encode; header -> strips CR/LF (anti-injection);
100
+ # body -> JSON escaping.
101
+ def build_request(kwargs)
102
+ r = @definition.request
103
+ url = interpolate(r[:url], kwargs, :url)
104
+ url = append_query(url, r[:query], kwargs)
105
+ headers = r[:headers].transform_values { |v| interpolate(v, kwargs, :header) }
106
+ body = r[:body] && interpolate(r[:body], kwargs, :body)
107
+ { method: r[:method], url: url, headers: headers, body: body, timeout: @definition.timeout }
108
+ end
109
+
110
+ def append_query(url, query, kwargs)
111
+ return url if query.nil? || query.empty?
112
+
113
+ pairs = query.map { |k, v| "#{ERB::Util.url_encode(k)}=#{interpolate(v, kwargs, :query)}" }
114
+ url + (url.include?("?") ? "&" : "?") + pairs.join("&")
115
+ end
116
+
117
+ def interpolate(template, kwargs, mode)
118
+ template.to_s.gsub(Insika::ToolDefinition::PLACEHOLDER_RE) do
119
+ encode(resolve(Regexp.last_match(1), kwargs), mode)
120
+ end
121
+ end
122
+
123
+ # ctx.* -> TURN context (deposited by the Executor); the rest -> MODEL args
124
+ # (kwargs). The split is the D2/R2 trust boundary: the model does
125
+ # not choose which chat/store the tool accesses.
126
+ def resolve(name, kwargs)
127
+ prefix = Insika::ToolDefinition::CTX_PREFIX
128
+ if name.start_with?(prefix)
129
+ @turn_context[name.delete_prefix(prefix).to_sym]
130
+ else
131
+ kwargs[name.to_sym]
132
+ end
133
+ end
134
+
135
+ def symbolize_ctx(ctx)
136
+ (ctx || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
137
+ end
138
+
139
+ def encode(value, mode)
140
+ case mode
141
+ when :url, :query then ERB::Util.url_encode(value.to_s)
142
+ when :header then value.to_s.gsub(/[\r\n]/, "")
143
+ when :body then value.is_a?(String) ? value.to_json[1..-2] : value.to_json
144
+ end
145
+ end
146
+
147
+ def extract(result)
148
+ case @definition.response[:extract]
149
+ when "status" then { status: result[:status] }
150
+ when "body_raw" then http_ok?(result) ? result[:body] : http_error(result)
151
+ when "json_path" then extract_json(result)
152
+ end
153
+ end
154
+
155
+ def extract_json(result)
156
+ return http_error(result) unless http_ok?(result)
157
+
158
+ parsed = begin
159
+ JSON.parse(result[:body])
160
+ rescue JSON::ParserError
161
+ return { error: "response is not JSON" }
162
+ end
163
+ dig_path(parsed, @definition.response[:path])
164
+ end
165
+
166
+ def dig_path(obj, path)
167
+ path.split(".").reduce(obj) do |cur, seg|
168
+ return { error: "path '#{path}' not found in the response" } unless cur.is_a?(Hash) && cur.key?(seg)
169
+
170
+ cur[seg]
171
+ end
172
+ end
173
+
174
+ # 2xx only. A 3xx is NOT success: the HttpClient does not follow redirects
175
+ # (the EgressGuard cleared the authored URL, not the hop's destination), and
176
+ # servers send a 3xx with an empty body — so treating it as ok handed the
177
+ # model "" and it narrated a plausible outage. A moved API must read as an
178
+ # error naming its new URL, which is a definition to fix.
179
+ def http_ok?(result) = result[:status] >= 200 && result[:status] < 300
180
+
181
+ # A non-2xx is an ERROR — the backend said so, and the model has to know the call
182
+ # failed. But the body of a failure is often the backend TALKING: an envelope with
183
+ # a status and an instruction ("chat not found — ask the person to start over").
184
+ # Flattening it into a 200-char slice of a string threw that away exactly when the
185
+ # model needed it most, so a JSON body rides along parsed, under its own key.
186
+ # A non-JSON body (an HTML error page) stays truncated: it is noise, not a message.
187
+ ERROR_BODY_MAX = 2_000
188
+
189
+ def http_error(result)
190
+ if (300..399).cover?(result[:status]) && result[:location]
191
+ return { error: "HTTP #{result[:status]}: moved to #{result[:location]}" }
192
+ end
193
+
194
+ raw = result[:body].to_s
195
+ parsed = parse_error_body(raw)
196
+ return { error: "HTTP #{result[:status]}", body: parsed } if parsed
197
+
198
+ { error: "HTTP #{result[:status]}: #{raw[0, 200]}" }
199
+ end
200
+
201
+ # -> parsed JSON body worth forwarding | nil (not JSON, or too big to be a message).
202
+ def parse_error_body(raw)
203
+ return nil if raw.empty? || raw.bytesize > ERROR_BODY_MAX
204
+
205
+ parsed = JSON.parse(raw)
206
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : nil
207
+ rescue JSON::ParserError
208
+ nil
209
+ end
210
+
211
+ # No task correlation (registry tool does not receive TurnState) -> meta {}.
212
+ # Emits only name + status: NEVER body/headers (0 secret leakage, R2).
213
+ def emit(status)
214
+ @event_stream&.emit(Insika::Event.new(
215
+ type: :data_tool_call, data: { tool: @definition.name, status: status }, meta: {}
216
+ ))
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # Level 2 of progressive disclosure: loads the full SKILL.md body
8
+ # on demand. Respects the agent's allowlist (the model does not load a
9
+ # skill that the policy did not expose).
10
+ #
11
+ # `require "ruby_llm"` stays in THIS file (it inherits from
12
+ # RubyLLM::Tool), which is why it does NOT enter lib/insika.rb: the Executor
13
+ # loads it lazily inside create_chat.
14
+ class LoadSkill < RubyLLM::Tool
15
+ description "Loads the complete instructions (SKILL.md) of a skill by name"
16
+ param :name, desc: "Exact skill name, as listed in <available_skills>"
17
+
18
+ # RubyLLM::Tool#name derives from self.class.name — for a nested class it produces
19
+ # "insika--tools--load_skill", not "load_skill" (which wire_callbacks/
20
+ # :skill_activated and SkillCatalog#format_for_prompt assume). Explicit
21
+ # override. Coexists with
22
+ # `param :name` (verified: the param is still present).
23
+ def name = "load_skill"
24
+
25
+ def initialize(catalog, allowed_names)
26
+ @catalog = catalog
27
+ @allowed = Array(allowed_names).map(&:to_s)
28
+ super()
29
+ end
30
+
31
+ def execute(name:)
32
+ return { error: "skill '#{name}' not available for this agent" } unless @allowed.include?(name.to_s)
33
+
34
+ skill = @catalog.find(name)
35
+ return { error: "skill '#{name}' not found" } unless skill
36
+
37
+ skill.body
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # Write path of the cross-session memory: the agent stores a
8
+ # fact (key+value, durable upsert) or a note (value without key) on demand.
9
+ # Deterministic — NO model call.
10
+ # System builtin (like load_skill/tool_search): `require "ruby_llm"` stays
11
+ # in THIS file, loaded lazily by the Executor in create_chat.
12
+ class Remember < RubyLLM::Tool
13
+ description "Stores information to remember in future conversations. Use `key` " \
14
+ "for a durable key-value fact (overwrites the previous one); omit " \
15
+ "`key` for a free-form note."
16
+ param :value, desc: "The content to remember"
17
+ param :key, desc: "Fact key (e.g.: 'plan', 'name'); omit for a note", required: false
18
+
19
+ # otherwise RubyLLM derives "insika--tools--remember" from the class name.
20
+ def name = "remember"
21
+
22
+ def initialize(store, tenant, event_stream:, state:)
23
+ @store = store
24
+ @tenant = tenant
25
+ @event_stream = event_stream
26
+ @state = state
27
+ super()
28
+ end
29
+
30
+ def execute(value:, key: nil)
31
+ if key.to_s.strip.empty?
32
+ note = @store.add_note(tenant: @tenant, text: value.to_s)
33
+ emit("note", note.id)
34
+ { remembered: "note", id: note.id }
35
+ else
36
+ @store.put_fact(tenant: @tenant, key: key.to_s, value: value.to_s)
37
+ emit("fact", key.to_s)
38
+ { remembered: "fact", key: key.to_s }
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def emit(kind, ref)
45
+ @event_stream.emit(Insika::Event.new(
46
+ type: :memory_written,
47
+ data: { kind: kind, key: ref },
48
+ meta: { task_id: @state.task.id, session_id: @state.task.session_id }
49
+ ))
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require_relative "agent_enum"
5
+
6
+ module Insika
7
+ module Tools
8
+ # In-process delegation to a CHILD agent (RFC-0010, item 21) — the Flue
9
+ # `session.task()` primitive. A system tool (like remember/load_skill): wired
10
+ # by the ChatBuilder ONLY when `profile.subagents` is present, so `require
11
+ # "ruby_llm"` stays in this file (loaded lazily in create_chat). NOT enveloped
12
+ # (system tool) — in the synchronous mode the child lives in the parent's
13
+ # envelope and re-runs on the parent's resume (RFC-0010 §6).
14
+ #
15
+ # It holds no delegation logic itself: `execute` reads the parent TurnState
16
+ # (the subagents allowlist + resolved model for inheritance + depth) and hands
17
+ # off to `Executor#run_subagent`, which spawns the isolated child turn and
18
+ # returns its result. The child result = its text + the linked child session
19
+ # id (§4.3 R3).
20
+ class Subagent < RubyLLM::Tool
21
+ description "Delegates a self-contained task to a specialized child agent. " \
22
+ "The child runs in an ISOLATED context (it does not see this " \
23
+ "conversation) — pass everything it needs in `message`. By default " \
24
+ "it BLOCKS and returns the child's final answer. Set async:true to " \
25
+ "fire-and-forget a long task: it returns immediately and the child's " \
26
+ "result arrives later as a new message on this conversation."
27
+ param :agent, desc: "Id of the child agent to delegate to (must be one this agent may spawn)"
28
+ param :message, desc: "The self-contained task/prompt for the child agent"
29
+ param :async, type: :boolean, required: false,
30
+ desc: "true = dispatch and continue (result delivered later); default false = wait for the answer"
31
+
32
+ # otherwise RubyLLM derives "insika--tools--subagent" from the class name.
33
+ def name = "spawn_subagent"
34
+
35
+ def initialize(runner:, state:)
36
+ @runner = runner
37
+ @state = state
38
+ @allowed = Array(state.profile.subagents).map(&:to_s)
39
+ super()
40
+ end
41
+
42
+ # The parent's allowlist is per-TURN data, so it is named per instance: the
43
+ # ids go into the description AND as an `enum` on `agent`. Measured, not
44
+ # guessed — with only "must be one this agent may spawn" in the schema, a
45
+ # real provider answered "let me check which agents are available" and then
46
+ # did the work itself instead of delegating. A model cannot call what it
47
+ # cannot name.
48
+ def description
49
+ return super if @allowed.empty?
50
+
51
+ "#{super} Agents you may spawn: #{@allowed.join(', ')}."
52
+ end
53
+
54
+ def params_schema
55
+ @agent_enum_schema ||= Insika::Tools::AgentEnum.inject(super, @allowed, path: %i[agent])
56
+ end
57
+
58
+ # The child result is returned to the model as the tool result. On error we
59
+ # return { error: } (never raise) — a bad `agent`/depth/child failure is a
60
+ # message to the model, not a turn-killer (parity with A2ARemote).
61
+ def execute(agent:, message:, async: false)
62
+ result = @runner.run_subagent(agent: agent.to_s, message: message.to_s,
63
+ parent_state: @state, async: async == true)
64
+ return { error: result[:error] } if result[:error]
65
+
66
+ # async dispatch: the ack (the child result arrives later as a new turn).
67
+ return { dispatched: true, agent: result[:agent], session_id: result[:session_id] } if result[:dispatched]
68
+
69
+ # sync: link the child session id alongside the text so a multi-step parent
70
+ # can reference it and the transcript stays auditable (§4.3 R3 / §7).
71
+ { text: result[:text], session_id: result[:session_id] }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require_relative "agent_enum"
5
+
6
+ module Insika
7
+ module Tools
8
+ # PARALLEL fan-out of child agents (RFC-0010 §A): delegate N self-contained
9
+ # tasks at once and get all answers back together, in ONE parent turn. Sibling
10
+ # of `spawn_subagent` (single); this one always sync-joins — the children run
11
+ # concurrently (their provider waits overlap on the reactor) and the combined
12
+ # result comes back as this tool's result. A system tool wired by the ChatBuilder
13
+ # when `profile.subagents` is present; `require "ruby_llm"` stays in this file
14
+ # (loaded lazily in create_chat). NOT enveloped (children live in the parent's
15
+ # envelope, same as the sync single).
16
+ class Subagents < RubyLLM::Tool
17
+ description "Delegates SEVERAL self-contained tasks to child agents IN " \
18
+ "PARALLEL and returns all their answers together. Use this — not " \
19
+ "repeated spawn_subagent calls — when you have multiple independent " \
20
+ "subtasks whose results you'll combine: it runs them concurrently " \
21
+ "(much faster). Each child runs in an ISOLATED context, so put " \
22
+ "everything it needs in its `message`."
23
+ # array-of-objects param via the explicit JSON-schema form (the `param` DSL
24
+ # only reaches strings/scalars). Top-level `tasks` arrives as a kwarg to execute.
25
+ params(
26
+ type: "object",
27
+ properties: {
28
+ tasks: {
29
+ type: "array",
30
+ description: "The independent subtasks to run in parallel",
31
+ items: {
32
+ type: "object",
33
+ properties: {
34
+ agent: { type: "string", description: "id of the child agent (must be one this agent may spawn)" },
35
+ message: { type: "string", description: "the self-contained task/prompt for that child" }
36
+ },
37
+ required: %w[agent message]
38
+ }
39
+ }
40
+ },
41
+ required: %w[tasks]
42
+ )
43
+
44
+ # otherwise RubyLLM derives "insika--tools--subagents" from the class name.
45
+ def name = "spawn_subagents"
46
+
47
+ def initialize(runner:, state:)
48
+ @runner = runner
49
+ @state = state
50
+ @allowed = Array(state.profile.subagents).map(&:to_s)
51
+ super()
52
+ end
53
+
54
+ # Same reason as `spawn_subagent`: the parent's allowlist is per-turn data,
55
+ # so the ids are named per instance — in the description and as an `enum` on
56
+ # each task's `agent`. See Tools::AgentEnum.
57
+ def description
58
+ return super if @allowed.empty?
59
+
60
+ "#{super} Agents you may spawn: #{@allowed.join(', ')}."
61
+ end
62
+
63
+ def params_schema
64
+ @agent_enum_schema ||= Insika::Tools::AgentEnum.inject(super, @allowed, path: %i[tasks agent])
65
+ end
66
+
67
+ # -> { results: [{agent:, text:, session_id:} | {agent:, error:}] } | { error: }.
68
+ # Never raises: a bad envelope / per-task failure is a message to the model.
69
+ def execute(tasks:)
70
+ result = @runner.run_subagents(tasks: Array(tasks), parent_state: @state)
71
+ return { error: result[:error] } if result[:error]
72
+
73
+ { results: result[:results] }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # Level 2 of TOOLS progressive disclosure (analog of LoadSkill):
8
+ # searches the deferred catalog and PROMOTES the relevant ones into the live chat via
9
+ # chat.with_tools (verified to propagate on the next round of the same `ask`
10
+ # in ruby_llm 1.16). `require "ruby_llm"` stays in THIS file (it inherits from
11
+ # RubyLLM::Tool) — it does not enter lib/insika.rb; the Executor loads it lazily in
12
+ # configure_chat (like LoadSkill).
13
+ class ToolSearch < RubyLLM::Tool
14
+ description "Searches and enables additional tools by describing the need"
15
+ param :query, desc: "What you need to do (e.g.: 'send email', 'generate invoice')"
16
+
17
+ # RubyLLM::Tool#name derives from self.class.name — for a nested class
18
+ # (Insika::Tools::ToolSearch) it produces "insika--tools--tool_search", not
19
+ # "tool_search". Explicit override: the name the model calls must match
20
+ # the catalog/docs/tests.
21
+ def name = "tool_search"
22
+
23
+ def initialize(catalog, deferred_allowed, chat, tool_registry:, event_stream:,
24
+ checkpoint_store:, state:)
25
+ @catalog = catalog
26
+ @deferred_allowed = Array(deferred_allowed).map(&:to_s)
27
+ @chat = chat
28
+ @tool_registry = tool_registry
29
+ @event_stream = event_stream
30
+ @checkpoint_store = checkpoint_store
31
+ @state = state
32
+ @promoted = [] # names already promoted IN THIS chat — idempotency
33
+ super()
34
+ end
35
+
36
+ def execute(query:)
37
+ matches = @catalog.search(query, within: @deferred_allowed)
38
+ emit_tool_search(query, matches.map(&:name))
39
+ if matches.empty?
40
+ return { matched: [], message: "no tool found for '#{query}'" }
41
+ end
42
+
43
+ new_matches = matches.reject { |m| @promoted.include?(m.name) }
44
+ promote(new_matches) unless new_matches.empty?
45
+
46
+ { matched: matches.map { |m| describe(m) } }
47
+ end
48
+
49
+ private
50
+
51
+ # Instantiates (via tool_registry), wraps in the SAME ToolEnvelope as the eager
52
+ # ones (profile timeout + state's skip_side_effects) and promotes via
53
+ # chat.with_tools. A NotFoundError (misaligned catalog) drops only
54
+ # that match — the search does not break.
55
+ def promote(entries)
56
+ timeout = @state.profile.limits[:tool_timeout] || 60
57
+ wrapped = entries.filter_map do |entry|
58
+ tool = @tool_registry.resolve(entry.name)
59
+ @promoted << entry.name
60
+ ToolEnvelope.new(tool, state: @state, checkpoint_store: @checkpoint_store,
61
+ tool_registry: @tool_registry, timeout: timeout,
62
+ skip_side_effects: Array(@state.skip_side_effects))
63
+ rescue Insika::NotFoundError
64
+ nil
65
+ end
66
+ @chat.with_tools(*wrapped) unless wrapped.empty?
67
+ end
68
+
69
+ # Mirrors :skill_activated, but emitted by the tool itself (it has event_stream/
70
+ # state in the constructor). Without a monotonic `seq` (private to the Executor) — a
71
+ # documented gap, not a blocker.
72
+ def emit_tool_search(query, matched_names)
73
+ @event_stream.emit(Insika::Event.new(
74
+ type: :tool_search,
75
+ data: { query: query, matched: matched_names },
76
+ meta: { task_id: @state.task.id, session_id: @state.task.session_id }
77
+ ))
78
+ end
79
+
80
+ def describe(entry)
81
+ tool = @tool_registry.resolve(entry.name)
82
+ {
83
+ name: entry.name,
84
+ description: entry.description,
85
+ parameters: tool.parameters.transform_values do |p|
86
+ { type: p.type, description: p.description, required: p.required }
87
+ end
88
+ }
89
+ rescue Insika::NotFoundError
90
+ { name: entry.name, description: entry.description, parameters: {} }
91
+ end
92
+ end
93
+ end
94
+ end