insika 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/semaphore"
5
+
6
+ module Insika
7
+ module Tools
8
+ # Fan-out INSIDE a single tool (§): when a tool must gather N
9
+ # independent I/O calls (stock + price + promo + delivery) into ONE result,
10
+ # `gather` runs them CONCURRENTLY on the turn's reactor and returns their values
11
+ # IN ORDER. Because a data-tool spends its time on the HTTP wait, those waits
12
+ # overlap → wall-clock ≈ the slowest call, not the sum.
13
+ #
14
+ # This is the "aggregator tool" pattern: the model makes ONE tool call and the
15
+ # concurrency is an implementation detail of that tool — no change to the agent
16
+ # loop, no dependency on parallel tool execution. Use it in a custom
17
+ # Ruby tool's #execute:
18
+ #
19
+ # def execute(store_id:)
20
+ # stock, price, promo = Insika::Tools::Concurrency.gather(
21
+ # -> { fetch_stock(store_id) },
22
+ # -> { fetch_price(store_id) },
23
+ # -> { fetch_promo(store_id) }
24
+ # )
25
+ # { stock:, price:, promo: }
26
+ # end
27
+ #
28
+ # Bounded by `max` concurrent (default 8) to respect upstream rate limits. Runs
29
+ # correctly whether or not there is already a reactor (a tool runs inside the
30
+ # turn's Async fiber; `Sync` reuses it, and starts one otherwise for tests).
31
+ module Concurrency
32
+ DEFAULT_MAX = 8
33
+
34
+ module_function
35
+
36
+ # blocks: callables (procs/lambdas) or a block yielding an index. Returns an
37
+ # Array of results aligned to the input order. An exception in any block
38
+ # propagates (the caller decides how to degrade — this helper does not swallow).
39
+ def gather(*blocks, max: DEFAULT_MAX)
40
+ blocks = blocks.flatten
41
+ return [] if blocks.empty?
42
+
43
+ results = Array.new(blocks.size)
44
+ Sync do
45
+ semaphore = Async::Semaphore.new([max, blocks.size].min)
46
+ blocks.each_with_index.map do |blk, i|
47
+ semaphore.async { results[i] = blk.call }
48
+ end.each(&:wait)
49
+ end
50
+ results
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,219 @@
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
+ #
14
+ # Contract preserved by duck-typing: it overrides name/description/parameters/
15
+ # execute; RubyLLM's params_schema derives from #parameters automatically.
16
+ # execute NEVER raises — an error (missing param, blocked egress, HTTP, parse)
17
+ # becomes `{ error: }` to the model, like the other tools.
18
+ class DataDefinedTool < RubyLLM::Tool
19
+ def initialize(definition:, http:, egress: Insika::EgressGuard, egress_options: {},
20
+ event_stream: nil, turn_context: {})
21
+ @definition = definition
22
+ @http = http
23
+ @egress = egress
24
+ @egress_options = egress_options
25
+ @event_stream = event_stream
26
+ @turn_context = symbolize_ctx(turn_context)
27
+ super()
28
+ end
29
+
30
+ # Turn context: the registry tool does NOT receive TurnState,
31
+ # so the Executor DEPOSITS the turn ids here, per-turn
32
+ # (chat/agent/tenant/store). They resolve {{ctx.*}} — SEPARATE from the model's
33
+ # {{param}} — to emit X-Chat-Id/X-Store-Id/X-Agent-Id. They come from the TURN, never from
34
+ # the model (R2). Reader for testing; the writer is the Executor's injection point.
35
+ attr_reader :turn_context
36
+
37
+ def turn_context=(ctx)
38
+ @turn_context = symbolize_ctx(ctx)
39
+ end
40
+
41
+ # name/description/parameters per INSTANCE (otherwise the model would see the name
42
+ # derived from the class for every data-tool).
43
+ def name = @definition.name
44
+ def description = @definition.description
45
+
46
+ # FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
47
+ # the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
48
+ # params_schema; parameters is just a fallback). Provider-agnostic and
49
+ # the only form that expresses nesting (object/array/enum).,.
50
+ def params_schema = @definition.parameters
51
+
52
+ # FLAT top-level view for discovery (tool_search calls #parameters on the resolved
53
+ # tool). The real nested schema goes through #params_schema above.
54
+ def parameters
55
+ @parameters ||= @definition.top_level_params.each_with_object({}) do |p, acc|
56
+ sym = p[:name].to_sym
57
+ acc[sym] = RubyLLM::Parameter.new(sym, type: p[:type], desc: p[:description], required: p[:required])
58
+ end
59
+ end
60
+
61
+ # The args are checked against the tool's own JSON Schema BEFORE the request is
62
+ # built: a call the schema does not allow becomes `{ error: }` the model can act
63
+ # on, instead of a wrongly-shaped request that a backend answers 200 to.
64
+ def execute(**kwargs)
65
+ if (bad = Insika::SchemaGuard.violation(@definition.parameters, kwargs))
66
+ return { error: bad }
67
+ end
68
+
69
+ req = build_request(kwargs)
70
+ reason = @egress.violation(req[:url], **@egress_options)
71
+ return { error: "destination blocked: #{reason}" } if reason
72
+
73
+ result = @http.request(**req)
74
+ emit(result[:status])
75
+ payload = extract(result)
76
+ # The RESPONSE says the turn is over (`halt_when`): the backend already
77
+ # answered the customer, so letting the model comment would deliver the
78
+ # message twice. RubyLLM's Tool::Halt ends its loop right here — no second
79
+ # provider call, and the decision is the engine's, not a request in a prompt.
80
+ # Only on a 2xx: an error body that happens to carry the value is a failure,
81
+ # and a failure must reach the model.
82
+ if http_ok?(result) && @definition.halt?(result[:body])
83
+ # `say` (optional) travels WITH the halt so the Executor can publish it when
84
+ # the model wrote no lead-in. Wrapped only when there is one, so every tool
85
+ # that declares no `say` keeps producing exactly the payload it always did.
86
+ say = @definition.halt_say(result[:body])
87
+ return RubyLLM::Tool::Halt.new(say ? Insika::ToolDefinition.wrap_halt(payload, say) : payload)
88
+ end
89
+
90
+ payload
91
+ rescue StandardError => e
92
+ { error: "HTTP call failed: #{e.message}" }
93
+ end
94
+
95
+ private
96
+
97
+ # Interpolates the definition's templates with the model's args, escaping by
98
+ # context: url/query -> percent-encode; header -> strips CR/LF (anti-injection);
99
+ # body -> JSON escaping.
100
+ def build_request(kwargs)
101
+ r = @definition.request
102
+ url = interpolate(r[:url], kwargs, :url)
103
+ url = append_query(url, r[:query], kwargs)
104
+ headers = r[:headers].transform_values { |v| interpolate(v, kwargs, :header) }
105
+ body = r[:body] && interpolate(r[:body], kwargs, :body)
106
+ { method: r[:method], url: url, headers: headers, body: body, timeout: @definition.timeout }
107
+ end
108
+
109
+ def append_query(url, query, kwargs)
110
+ return url if query.nil? || query.empty?
111
+
112
+ pairs = query.map { |k, v| "#{ERB::Util.url_encode(k)}=#{interpolate(v, kwargs, :query)}" }
113
+ url + (url.include?("?") ? "&" : "?") + pairs.join("&")
114
+ end
115
+
116
+ def interpolate(template, kwargs, mode)
117
+ template.to_s.gsub(Insika::ToolDefinition::PLACEHOLDER_RE) do
118
+ encode(resolve(Regexp.last_match(1), kwargs), mode)
119
+ end
120
+ end
121
+
122
+ # ctx.* -> TURN context (deposited by the Executor); the rest -> MODEL args
123
+ # (kwargs). The split is the/R2 trust boundary: the model does
124
+ # not choose which chat/store the tool accesses.
125
+ def resolve(name, kwargs)
126
+ prefix = Insika::ToolDefinition::CTX_PREFIX
127
+ if name.start_with?(prefix)
128
+ @turn_context[name.delete_prefix(prefix).to_sym]
129
+ else
130
+ kwargs[name.to_sym]
131
+ end
132
+ end
133
+
134
+ def symbolize_ctx(ctx)
135
+ (ctx || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
136
+ end
137
+
138
+ def encode(value, mode)
139
+ case mode
140
+ when :url, :query then ERB::Util.url_encode(value.to_s)
141
+ when :header then value.to_s.gsub(/[\r\n]/, "")
142
+ when :body then value.is_a?(String) ? value.to_json[1..-2] : value.to_json
143
+ end
144
+ end
145
+
146
+ def extract(result)
147
+ case @definition.response[:extract]
148
+ when "status" then { status: result[:status] }
149
+ when "body_raw" then http_ok?(result) ? result[:body] : http_error(result)
150
+ when "json_path" then extract_json(result)
151
+ end
152
+ end
153
+
154
+ def extract_json(result)
155
+ return http_error(result) unless http_ok?(result)
156
+
157
+ parsed = begin
158
+ JSON.parse(result[:body])
159
+ rescue JSON::ParserError
160
+ return { error: "response is not JSON" }
161
+ end
162
+ dig_path(parsed, @definition.response[:path])
163
+ end
164
+
165
+ def dig_path(obj, path)
166
+ path.split(".").reduce(obj) do |cur, seg|
167
+ return { error: "path '#{path}' not found in the response" } unless cur.is_a?(Hash) && cur.key?(seg)
168
+
169
+ cur[seg]
170
+ end
171
+ end
172
+
173
+ # 2xx only. A 3xx is NOT success: the HttpClient does not follow redirects
174
+ # (the EgressGuard cleared the authored URL, not the hop's destination), and
175
+ # servers send a 3xx with an empty body — so treating it as ok handed the
176
+ # model "" and it narrated a plausible outage. A moved API must read as an
177
+ # error naming its new URL, which is a definition to fix.
178
+ def http_ok?(result) = result[:status] >= 200 && result[:status] < 300
179
+
180
+ # A non-2xx is an ERROR — the backend said so, and the model has to know the call
181
+ # failed. But the body of a failure is often the backend TALKING: an envelope with
182
+ # a status and an instruction ("chat not found — ask the person to start over").
183
+ # Flattening it into a 200-char slice of a string threw that away exactly when the
184
+ # model needed it most, so a JSON body rides along parsed, under its own key.
185
+ # A non-JSON body (an HTML error page) stays truncated: it is noise, not a message.
186
+ ERROR_BODY_MAX = 2_000
187
+
188
+ def http_error(result)
189
+ if (300..399).cover?(result[:status]) && result[:location]
190
+ return { error: "HTTP #{result[:status]}: moved to #{result[:location]}" }
191
+ end
192
+
193
+ raw = result[:body].to_s
194
+ parsed = parse_error_body(raw)
195
+ return { error: "HTTP #{result[:status]}", body: parsed } if parsed
196
+
197
+ { error: "HTTP #{result[:status]}: #{raw[0, 200]}" }
198
+ end
199
+
200
+ # -> parsed JSON body worth forwarding | nil (not JSON, or too big to be a message).
201
+ def parse_error_body(raw)
202
+ return nil if raw.empty? || raw.bytesize > ERROR_BODY_MAX
203
+
204
+ parsed = JSON.parse(raw)
205
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : nil
206
+ rescue JSON::ParserError
207
+ nil
208
+ end
209
+
210
+ # No task correlation (registry tool does not receive TurnState) -> meta {}.
211
+ # Emits only name + status: NEVER body/headers (0 secret leakage, R2).
212
+ def emit(status)
213
+ @event_stream&.emit(Insika::Event.new(
214
+ type: :data_tool_call, data: { tool: @definition.name, status: status }, meta: {}
215
+ ))
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "time"
5
+
6
+ module Insika
7
+ module Tools
8
+ # Level 2 of progressive disclosure: loads the full SKILL.md body
9
+ # on demand. Respects the agent's allowlist (the model does not load a
10
+ # skill that the policy did not expose).
11
+ #
12
+ # `require "ruby_llm"` stays in THIS file (it inherits from
13
+ # RubyLLM::Tool), which is why it does NOT enter lib/insika.rb: the Executor
14
+ # loads it lazily inside create_chat.
15
+ class LoadSkill < RubyLLM::Tool
16
+ description "Loads the complete instructions (SKILL.md) of a skill by name"
17
+ param :name, desc: "Exact skill name, as listed in <available_skills>"
18
+
19
+ # RubyLLM::Tool#name derives from self.class.name — for a nested class it produces
20
+ # "insika--tools--load_skill", not "load_skill" (which wire_callbacks/
21
+ # :skill_activated and SkillCatalog#format_for_prompt assume). Explicit
22
+ # override. Coexists with
23
+ # `param :name` (verified: the param is still present).
24
+ def name = "load_skill"
25
+
26
+ # trace_recorder/state are OPTIONAL (nil = no trace, parity): this tool is
27
+ # deliberately NOT enveloped (ToolAssembly#wrap_tools), and the envelope is
28
+ # what records the tool trace — so without recording HERE, the one call an
29
+ # operator most needs to audit is the only one missing from the Studio's
30
+ # trace. Same shape as ToolSearch, which also emits its own event.
31
+ # `agent` selects WHICH body a name resolves to: an agent that specialized a
32
+ # shared skill must be served its own version, under the same bare name. nil =
33
+ # the shared scope only (parity).
34
+ def initialize(catalog, allowed_names, trace_recorder: nil, state: nil, agent: nil)
35
+ @catalog = catalog
36
+ @allowed = Array(allowed_names).map(&:to_s)
37
+ @trace_recorder = trace_recorder
38
+ @state = state
39
+ @agent = agent
40
+ super()
41
+ end
42
+
43
+ def execute(name:)
44
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ result = load(name)
46
+ trace(name, result, started)
47
+ result
48
+ end
49
+
50
+ private
51
+
52
+ def load(name)
53
+ return { error: "skill '#{name}' not available for this agent" } unless @allowed.include?(name.to_s)
54
+
55
+ skill = @catalog.find(name, agent: @agent)
56
+ return { error: "skill '#{name}' not found" } unless skill
57
+
58
+ with_companions(skill)
59
+ end
60
+
61
+ # A skill's declared `companions:` come back in the SAME call, so the model
62
+ # cannot end up holding half a recipe (the reference table without the procedure
63
+ # that reads it — measured on a real pack, and the searches came out malformed).
64
+ #
65
+ # A lone skill returns its bare body, byte for byte as before: only a skill that
66
+ # actually declares companions pays the wrapper. Restricted to `@allowed`, which
67
+ # is the LAZY allowed set — a companion that is eager is already in the prompt in
68
+ # full, so fetching it again would only buy a duplicate.
69
+ def with_companions(skill)
70
+ extras = Array(skill.companions).filter_map do |name|
71
+ next unless @allowed.include?(name.to_s) && name.to_s != skill.name
72
+
73
+ @catalog.find(name, agent: @agent)
74
+ end
75
+ return skill.body if extras.empty?
76
+
77
+ ([skill] + extras).uniq(&:name)
78
+ .map { |s| %(<skill name="#{s.name}">\n#{s.body}\n</skill>) }.join("\n\n")
79
+ end
80
+
81
+ # Mirrors ToolEnvelope#trace (same entry shape, so the Studio renders it
82
+ # like any other call). Clipping/masking is the ToolTraceStore's job.
83
+ # NEVER breaks the turn — the trace is observability.
84
+ def trace(name, result, started)
85
+ return unless @trace_recorder && @state&.task&.session_id
86
+
87
+ @trace_recorder.record(
88
+ session_id: @state.task.session_id,
89
+ entry: { "turn" => @state.turn, "tool" => "load_skill", "call_id" => "",
90
+ "args" => { "name" => name.to_s }, "result" => result,
91
+ "ms" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
92
+ "at" => Time.now.utc.iso8601 }
93
+ )
94
+ rescue StandardError
95
+ nil
96
+ end
97
+ end
98
+ end
99
+ 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,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # The agent's deterministic "I cannot proceed" signal (WS5). A
8
+ # system builtin like remember: the model calls it when it determines it cannot
9
+ # continue (out of scope, missing data, a case a human must take over). It ends
10
+ # the turn with `outcome: :stuck` recorded in the contract (the executor reads
11
+ # `state.stuck_outcome` at stage 8/9 and tags the terminal event) and a final
12
+ # message: the model's lead-in when it wrote one, else this tool's `message`.
13
+ #
14
+ # The ENGINE does not decide what "stuck" means — the consumer does. This tool
15
+ # is the deterministic signal the Agent.Shop subscribes to (`:turn_stuck` /
16
+ # `outcome: "stuck"`) to run its escalation (CRM/operator, which answers with
17
+ # `MessageOrigin::OPERATOR`). Nothing about handoff, pause or resume lives here.
18
+ class StuckSignal < RubyLLM::Tool
19
+ description "Signal that you cannot proceed and end the turn. Use when the " \
20
+ "request is out of your scope, you lack the data to help, or a human " \
21
+ "must take over. Write your final sentence to the customer first."
22
+ param :reason, desc: "Why you cannot proceed (goes to the operator, not the customer)"
23
+ param :message, desc: "Optional final message if you wrote none", required: false
24
+
25
+ def name = "signal_stuck"
26
+
27
+ def initialize(state:, **)
28
+ @state = state
29
+ super()
30
+ end
31
+
32
+ def execute(reason:, message: nil)
33
+ @state.stuck_outcome = { reason: reason.to_s, message: message.to_s }
34
+ # A Halt ends the tool loop here, so the turn cannot continue after declaring
35
+ # stuck. The payload's `say` is the fallback final message when the model
36
+ # wrote no lead-in (the executor's halt_answer already prefers the lead-in).
37
+ RubyLLM::Tool::Halt.new(Insika::ToolDefinition.wrap_halt(
38
+ { "reason" => reason.to_s },
39
+ message.to_s
40
+ ))
41
+ end
42
+ end
43
+ end
44
+ 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 — 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.
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 (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 (R3).
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: 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