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,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Tool manifest (Phase 7, Step B): the BATCH, DATA-driven, industry-STANDARD
5
+ # (JSON Schema) form of the data-tools. A `defaults` (common binding:
6
+ # base_url/path_template/method/headers/secret_headers/response) + a list of
7
+ # `tools`. Each tool is normalized — inheriting the defaults, applying the envelope
8
+ # adapter (OpenAI/MCP/raw) and resolving `endpoint`→url + `{{secret.*}}`/
9
+ # `{{env.*}}` — into a ToolDefinition Hash consumable by the ToolStore.
10
+ #
11
+ # GENERIC (NF1): nothing here mentions achei/openclaw — the manifest IS the contract. It
12
+ # only NORMALIZES/RESOLVES; the final validation (method/url/types, model placeholders
13
+ # `{{param}}`/turn `{{ctx.*}}`, safe subset of JSON Schema) belongs to
14
+ # ToolDefinition (single source). The batch upsert + hot reload belongs to the
15
+ # :import_tools Command (which injects the secret/env resolvers).
16
+ #
17
+ # Two placeholder classes by resolution MOMENT:
18
+ # - `{{param}}`/`{{ctx.*}}` : resolved AT THE TURN (by DataDefinedTool) —
19
+ # they stay INTACT here; ToolDefinition validates them.
20
+ # - `{{secret.*}}`/`{{env.*}}` : resolved AT INGESTION (here) — from the deployment,
21
+ # never from the manifest (D6/R3). `{{env.*}}` is non-secret config (any
22
+ # template); `{{secret.*}}` is a credential and is ONLY allowed in a header
23
+ # declared in `secret_headers`, with value === `{{secret.X}}` (nothing literal,
24
+ # nothing outside a header — otherwise the secret would leak without masking).
25
+ #
26
+ # Interface envelope (what the model sees), by adapter (D3):
27
+ # - `parameters` (raw JSON Schema), OR
28
+ # - `{ "function": { name, description, parameters } }` (OpenAI/Anthropic), OR
29
+ # - `{ "inputSchema": {...} }` (MCP).
30
+ ToolManifest = Data.define(:version, :defaults, :tools)
31
+
32
+ # Reopens the class (constants inside a Data.define block land in the wrong LEXICAL
33
+ # scope — same pattern as the sibling ToolDefinition).
34
+ class ToolManifest
35
+ ENV_PREFIX = "env."
36
+ SECRET_PREFIX = "secret."
37
+ private_constant :ENV_PREFIX, :SECRET_PREFIX
38
+
39
+ # Raw Hash (string|symbol keys) -> ToolManifest. Validates only the top-level
40
+ # STRUCTURE (version/defaults/tools); each tool's content is validated during
41
+ # normalization (per-tool, isolable by the Command — R4). Raises ValidationError.
42
+ def self.from_h(hash)
43
+ h = stringify(hash || {})
44
+ version = h.fetch("version", 1)
45
+ defaults = h.fetch("defaults", {}) || {}
46
+ tools = h.fetch("tools", []) || []
47
+ raise Insika::ValidationError, "manifest: 'defaults' must be an object" unless defaults.is_a?(Hash)
48
+ raise Insika::ValidationError, "manifest: 'tools' must be a list" unless tools.is_a?(Array)
49
+
50
+ new(version: version, defaults: defaults, tools: tools)
51
+ end
52
+
53
+ # -> [ { ToolDefinition hash } ] with inherited defaults, normalized envelopes,
54
+ # resolved endpoint→url and resolved secret/env. Raises on the FIRST malformed
55
+ # tool — use #definition_for per-tool to isolate partial failure (R4).
56
+ def tool_definitions(secrets:, env:)
57
+ tools.map { |t| definition_for(t, secrets: secrets, env: env) }
58
+ end
59
+
60
+ # Normalizes ONE raw tool -> ToolDefinition Hash. Raises ValidationError
61
+ # (invalid envelope, missing endpoint/url, secret/env not configured, R3).
62
+ def definition_for(raw_tool, secrets:, env:)
63
+ t = self.class.stringify(raw_tool || {})
64
+ raise Insika::ValidationError, "tool must be an object" unless t.is_a?(Hash)
65
+
66
+ interface = extract_interface(t)
67
+ binding = build_binding(t, secrets, env)
68
+
69
+ {
70
+ "name" => interface[:name], "description" => interface[:description],
71
+ "parameters" => interface[:parameters],
72
+ "request" => binding[:request],
73
+ "response" => binding[:response],
74
+ "secret_headers" => binding[:secret_headers],
75
+ "side_effect" => t["side_effect"],
76
+ "group" => t["group"] || defaults["group"], # Phase 7/D4/F5 (Step C):
77
+ "tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
78
+ # Per-tool only: "this result ends the turn" is a property of THIS backend's
79
+ # response, never something a manifest default should hand to its siblings.
80
+ "halt_when" => t["halt_when"]
81
+ }.compact
82
+ end
83
+
84
+ private
85
+
86
+ # Envelope adapter (D3): extracts the INTERFACE (name/description/parameters) from
87
+ # any standard form. name/description fall back to the tool's top level when the
88
+ # envelope does not carry them.
89
+ def extract_interface(t)
90
+ fn = t["function"]
91
+ if fn.is_a?(Hash) # OpenAI/Anthropic function tool
92
+ { name: fn["name"] || t["name"], description: fn["description"] || t["description"],
93
+ parameters: fn["parameters"] }
94
+ elsif t.key?("inputSchema") # MCP tool
95
+ { name: t["name"], description: t["description"], parameters: t["inputSchema"] }
96
+ else # raw: parameters is JSON Schema
97
+ { name: t["name"], description: t["description"], parameters: t["parameters"] }
98
+ end
99
+ end
100
+
101
+ # Builds the binding (request/response/secret_headers) inheriting defaults, with the
102
+ # tool winning. Resolves endpoint→url, secret (only in secret headers) and env.
103
+ def build_binding(t, secrets, env)
104
+ method = (t["method"] || defaults["method"] || "GET").to_s.upcase
105
+ headers = merge_maps(defaults["headers"], t["headers"])
106
+ query = merge_maps(defaults["query"], t["query"])
107
+ body = t.key?("body") ? t["body"] : defaults["body"]
108
+ secret_headers = (Array(defaults["secret_headers"]) | Array(t["secret_headers"])).map(&:to_s)
109
+ response = t["response"] || defaults["response"] || { "extract" => "body_raw" }
110
+
111
+ url = resolve_url(t, env)
112
+ headers = resolve_headers(headers, secret_headers, secrets, env)
113
+ query = query.transform_values { |v| resolve_env(v, env) }
114
+ body = body.nil? ? nil : resolve_env(body, env)
115
+ guard_no_stray_secrets!(url: url, headers: headers, secret_headers: secret_headers, query: query, body: body)
116
+
117
+ { request: { "method" => method, "url" => url, "headers" => headers,
118
+ "query" => query, "body" => body }.compact,
119
+ response: response, secret_headers: secret_headers }
120
+ end
121
+
122
+ # explicit tool url OR base_url + path_template({endpoint}). The `endpoint` is
123
+ # DATA (never inferred from the name — R6): it resolves name↔slug remaps. `{endpoint}`
124
+ # is a manifest substitution (single key), distinct from the turn's `{{param}}`.
125
+ def resolve_url(t, env)
126
+ if self.class.presence(t["url"])
127
+ return resolve_env(t["url"], env)
128
+ end
129
+
130
+ base = self.class.presence(defaults["base_url"]) ||
131
+ (raise Insika::ValidationError, "tool '#{t['name']}' has no url and manifest has no defaults.base_url")
132
+ path = self.class.presence(defaults["path_template"]) ||
133
+ (raise Insika::ValidationError, "tool '#{t['name']}' has no url and manifest has no defaults.path_template")
134
+ endpoint = self.class.presence(t["endpoint"]) ||
135
+ (raise Insika::ValidationError, "tool '#{t['name']}' has no 'endpoint' (never inferred from the name — R6)")
136
+
137
+ resolve_env(base.to_s + path.to_s.gsub("{endpoint}", endpoint.to_s), env)
138
+ end
139
+
140
+ # Resolves headers: a SECRET header (name in secret_headers) MUST contain a
141
+ # {{secret.*}} reference (never literal — R3; a prefix like "Bearer " is ok) and
142
+ # resolves secret+env; the rest resolve only {{env.*}}.
143
+ def resolve_headers(headers, secret_headers, secrets, env)
144
+ headers.each_with_object({}) do |(name, value), acc|
145
+ acc[name] =
146
+ if secret_headers.include?(name)
147
+ resolve_secret_header(name, value, secrets, env)
148
+ else
149
+ resolve_env(value, env)
150
+ end
151
+ end
152
+ end
153
+
154
+ def resolve_secret_header(name, value, secrets, env)
155
+ unless value.to_s.match?(/\{\{\s*#{SECRET_PREFIX}/)
156
+ raise Insika::ValidationError,
157
+ "secret_header '#{name}' must reference {{secret.<name>}} (never a literal — R3)"
158
+ end
159
+
160
+ substitute(value, secrets: secrets, env: env)
161
+ end
162
+
163
+ # Substitutes {{env.X}} with the deployment value; leaves the other placeholders
164
+ # ({{param}}/{{ctx.*}}/{{secret.*}}) INTACT. Used on NON-secret fields — a
165
+ # remaining {{secret.*}} is blocked by #guard_no_stray_secrets! (it would leak).
166
+ def resolve_env(template, env)
167
+ substitute(template, secrets: nil, env: env)
168
+ end
169
+
170
+ # Ingestion interpolator: always resolves {{env.X}}; resolves {{secret.X}} only
171
+ # when `secrets` was passed (secret fields). TURN placeholders
172
+ # ({{param}}/{{ctx.*}}) stay INTACT. Missing secret/env -> ValidationError.
173
+ def substitute(template, secrets:, env:)
174
+ template.to_s.gsub(Insika::ToolDefinition::PLACEHOLDER_RE) do
175
+ ref = Regexp.last_match(1)
176
+ if secrets && ref.start_with?(SECRET_PREFIX)
177
+ fetch!(secrets, ref.delete_prefix(SECRET_PREFIX), "secret")
178
+ elsif ref.start_with?(ENV_PREFIX)
179
+ fetch!(env, ref.delete_prefix(ENV_PREFIX), "env")
180
+ else
181
+ Regexp.last_match(0) # preserves {{param}}/{{ctx.*}} (and {{secret.*}} outside a secret header)
182
+ end
183
+ end
184
+ end
185
+
186
+ def fetch!(resolver, key, kind)
187
+ val = resolver[key]
188
+ raise Insika::ValidationError, "#{kind} '#{key}' not configured in the deployment" if blank?(val)
189
+
190
+ val.to_s
191
+ end
192
+
193
+ # A secret outside a secret-header would leak (it is not masked): reject {{secret.*}}
194
+ # in url/query/body/non-secret header after env resolution.
195
+ def guard_no_stray_secrets!(url:, headers:, secret_headers:, query:, body:)
196
+ non_secret = headers.reject { |name, _| secret_headers.include?(name) }.values
197
+ strings = [url, *non_secret, *query.values, body].compact
198
+ stray = strings.flat_map { |s| s.to_s.scan(Insika::ToolDefinition::PLACEHOLDER_RE).flatten }
199
+ .select { |r| r.start_with?(SECRET_PREFIX) }.uniq
200
+ return if stray.empty?
201
+
202
+ raise Insika::ValidationError,
203
+ "{{secret.*}} is only allowed in secret_headers (would leak without masking): #{stray.join(', ')}"
204
+ end
205
+
206
+ def merge_maps(base, override)
207
+ (self.class.stringify(base || {})).merge(self.class.stringify(override || {}))
208
+ end
209
+
210
+ def blank?(v) = Insika::Coercion.blank?(v)
211
+
212
+ # Deep string keys (the wire may arrive symbolized; the nested JSON Schema
213
+ # must stay string-keyed — same as ToolDefinition's canonicalization).
214
+ def self.stringify(obj) = Insika::Coercion.deep_stringify(obj)
215
+
216
+ def self.presence(str) = Insika::Coercion.presence(str)
217
+ end
218
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Tool registry. Inherits from the generic one; `optional`/`side_effect`
5
+ # live in the metadata (defaults false). `side_effect: true` marks a
6
+ # non-idempotent tool — the checkpoint mechanism consumes it via `side_effect?`.
7
+ class ToolRegistry < Registry
8
+ # Legacy compat: `optional:`/`plugin:` are still accepted; `side_effect:` is
9
+ # new. Everything is normalized into the metadata (defaults false).
10
+ def register(name, callable = nil, optional: false, side_effect: false, plugin: nil, **extra, &block)
11
+ super(name, callable, plugin: plugin,
12
+ optional: optional, side_effect: side_effect, **extra, &block)
13
+ end
14
+
15
+ # -> bool; consumed by ToolEnvelope. Unknown tool = false.
16
+ def side_effect?(name)
17
+ entry = entries.find { |e| e.name == name.to_s }
18
+ entry ? !!entry.metadata[:side_effect] : false
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # DATA-DEFINED tools authored at runtime. One record per tool in the ConfigStore
7
+ # (scope "tools"), keyed by name. It stores the whole ToolDefinition, versions it
8
+ # (like SkillStore/AgentFileStore) and MASKS the credential headers (just as
9
+ # McpStore masks the env): headers whose name is in `secret_headers` never
10
+ # leave in plaintext to the UI — they become the sentinel `__OCULTO__`. Only `get_raw`/
11
+ # `all_raw` (consumed by DataDefinedTool/overlay, never the screen) return the
12
+ # real values. Phase 5, Step A.
13
+ #
14
+ # Record in the ConfigStore:
15
+ # { "definition" => { ...ToolDefinition#to_h... },
16
+ # "updated_at" => iso8601,
17
+ # "history" => [ { "definition" =>, "at" => }, ... ] }
18
+ class ToolStore
19
+ SCOPE = "tools"
20
+ HISTORY_MAX = 20
21
+
22
+ def initialize(config_store:)
23
+ @cs = config_store
24
+ end
25
+
26
+ # -> MASKED ToolDefinition Hash (secret headers with sentinel) | nil.
27
+ def get(name)
28
+ d = raw_definition(name)
29
+ d && mask_definition(d)
30
+ end
31
+
32
+ # -> ToolDefinition Hash with REAL headers | nil. Internal use (tool/overlay).
33
+ def get_raw(name) = raw_definition(name)
34
+
35
+ # -> [String] names, lexicographic order.
36
+ def names = @cs.keys(SCOPE)
37
+
38
+ # -> [Hash] MASKED definitions (for the UI).
39
+ def all
40
+ names.filter_map { |n| get(n) }
41
+ end
42
+
43
+ # -> [Hash] definitions with REAL headers (for the overlay/registry). Never to the screen.
44
+ def all_raw
45
+ names.filter_map { |n| raw_definition(n) }
46
+ end
47
+
48
+ # Writes (upsert). `attrs` is a ToolDefinition Hash (the UI may send secret
49
+ # headers as sentinel: preserves what was stored). create_only refuses to overwrite.
50
+ # Validates via ToolDefinition.from_h. -> MASKED definition Hash.
51
+ def write(attrs, create_only: false)
52
+ definition = Insika::ToolDefinition.from_h(attrs) # validates (raises ValidationError)
53
+ name = definition.name
54
+ existing = @cs.get(SCOPE, name)
55
+ raise Insika::ValidationError, "tool '#{name}' already exists" if create_only && existing
56
+
57
+ final = definition.to_h
58
+ final["request"]["headers"] = reconcile_secret_headers(
59
+ final["request"]["headers"], definition.secret_headers,
60
+ existing&.dig("definition", "request", "headers")
61
+ )
62
+
63
+ rec = build_record(final, existing)
64
+ @cs.put(SCOPE, name, rec)
65
+ mask_definition(final)
66
+ end
67
+
68
+ # -> bool (did it exist?).
69
+ def delete(name) = @cs.delete(SCOPE, name.to_s)
70
+
71
+ # -> [ { "definition" => MASKED, "at" => } ] most recent first.
72
+ def versions(name)
73
+ (record(name)&.fetch("history", []) || []).map do |h|
74
+ { "definition" => mask_definition(h["definition"]), "at" => h["at"] }
75
+ end
76
+ end
77
+
78
+ # Restores version `index` as the current definition (new write, versions the current).
79
+ # -> MASKED definition Hash.
80
+ def restore(name, index)
81
+ rec = record(name)
82
+ raise Insika::NotFoundError, "tool '#{name}' not found" unless rec
83
+
84
+ hist = rec.fetch("history", [])
85
+ i = Integer(index)
86
+ raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
87
+
88
+ # The historical version stores REAL headers -> rewrite directly (bypasses the
89
+ # input masking; write only re-validates the structure).
90
+ write(hist[i]["definition"])
91
+ end
92
+
93
+ private
94
+
95
+ def record(name) = @cs.get(SCOPE, name.to_s)
96
+
97
+ def raw_definition(name) = record(name)&.fetch("definition", nil)
98
+
99
+ def build_record(definition, current)
100
+ history = current ? current.fetch("history", []) : []
101
+ if current
102
+ history = [{ "definition" => current["definition"], "at" => current["updated_at"] }] + history
103
+ history = history.first(HISTORY_MAX)
104
+ end
105
+ { "definition" => definition, "updated_at" => Time.now.utc.iso8601, "history" => history }
106
+ end
107
+
108
+ # Each header in secret_headers becomes a sentinel; the rest pass through intact.
109
+ def mask_definition(definition)
110
+ secret = definition["secret_headers"] || []
111
+ return definition if secret.empty?
112
+
113
+ headers = (definition.dig("request", "headers") || {}).each_with_object({}) do |(k, v), acc|
114
+ acc[k] = secret.include?(k) ? SecretMasking.mask(v) : v
115
+ end
116
+ definition.merge("request" => definition["request"].merge("headers" => headers))
117
+ end
118
+
119
+ # Reconciles ONLY the secret headers against what was stored: sentinel preserves,
120
+ # a new string replaces, "" clears (removes the key). Non-secret pass through intact.
121
+ def reconcile_secret_headers(incoming, secret_names, existing)
122
+ old = existing || {}
123
+ secret_names.each_with_object(incoming.dup) do |hname, acc|
124
+ next unless acc.key?(hname)
125
+
126
+ value = SecretMasking.reconcile(acc[hname], old[hname])
127
+ if value.nil? || value.to_s.empty?
128
+ acc.delete(hname)
129
+ else
130
+ acc[hname] = value
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ # Per-session TOOL-CALL trace, for debugging in the Studio (FOLLOWUP §3.1). One
7
+ # record per session in the raw backend (scope "tool_traces") — RUNTIME data, next
8
+ # to sessions/tasks, NOT config (hence the raw `store:`, like SessionStore,
9
+ # not the ConfigStore). A capped LIST of entries; ToolEnvelope writes one
10
+ # per call (name + model args + result + status + ms). Serves
11
+ # `/studio/sessions/:id`.
12
+ #
13
+ # SECURITY lives HERE (the store owns it): the screen is for the operator, but the
14
+ # args/response may carry PII/credentials. Every write goes through mask (values of
15
+ # sensitive keys become a sentinel) + clip (truncates large fields). The ephemeral
16
+ # `data_tool_call` event (name+status, 0 leakage) still goes to the live cards;
17
+ # THIS is the durable, richer record, behind the Studio login.
18
+ class ToolTraceStore
19
+ SCOPE = "tool_traces"
20
+ MAX_PER_SESSION = 200 # tail per session (avoids growing without bound)
21
+ MAX_FIELD_CHARS = 2_000 # cap per args/result field
22
+ SECRET_KEY_RE = /token|secret|authorization|password|passwd|api[-_]?key|bearer|cookie/i
23
+
24
+ def initialize(store:)
25
+ @store = store
26
+ end
27
+
28
+ # Writes a (sanitized) entry into the session. Missing session_id -> no-op.
29
+ # ToolEnvelope already guards, but so does this: the trace NEVER breaks the turn.
30
+ def record(session_id:, entry:)
31
+ sid = session_id.to_s
32
+ return if sid.empty?
33
+
34
+ list = (@store.get(SCOPE, sid) || []) + [sanitize(entry)]
35
+ @store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ # -> [Hash] session entries in chronological order. [] if none.
41
+ def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
42
+
43
+ # Discards a session's trace (cleanup). -> bool (did it exist?).
44
+ def clear(session_id) = @store.delete(SCOPE, session_id.to_s)
45
+
46
+ private
47
+
48
+ def sanitize(entry)
49
+ e = stringify_keys(entry)
50
+ {
51
+ "turn" => e["turn"], "tool" => e["tool"].to_s, "call_id" => e["call_id"].to_s,
52
+ "ok" => ok?(e["result"]),
53
+ "args" => clip(mask(e["args"])), "result" => clip(mask(e["result"])),
54
+ "ms" => e["ms"], "at" => e["at"].to_s
55
+ }
56
+ end
57
+
58
+ # Conventional tool error = Hash with key "error"/:error (everything else is ok).
59
+ def ok?(result)
60
+ !(result.is_a?(Hash) && (result.key?("error") || result.key?(:error)))
61
+ end
62
+
63
+ # Masks values of sensitive keys (recursive); the rest pass through intact.
64
+ def mask(obj)
65
+ case obj
66
+ when Hash
67
+ obj.each_with_object({}) do |(k, v), acc|
68
+ acc[k.to_s] = SECRET_KEY_RE.match?(k.to_s) ? SecretMasking::SENTINEL : mask(v)
69
+ end
70
+ when Array then obj.map { |v| mask(v) }
71
+ else obj
72
+ end
73
+ end
74
+
75
+ # -> Readable, capped String (JSON when structured). Truncates by CHARACTER
76
+ # (not byte — avoids cutting UTF-8 mid-sequence).
77
+ def clip(obj)
78
+ s = obj.is_a?(String) ? obj : safe_json(obj)
79
+ s.length > MAX_FIELD_CHARS ? "#{s[0, MAX_FIELD_CHARS]}…(truncado)" : s
80
+ end
81
+
82
+ def safe_json(obj)
83
+ JSON.generate(obj)
84
+ rescue StandardError
85
+ obj.inspect
86
+ end
87
+
88
+ def stringify_keys(hash)
89
+ (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # Delegates to a remote A2A agent. A NORMAL Tool Registry tool — the
8
+ # agent's allowlist governs who may delegate. Lazy require of the gem: it does NOT
9
+ # enter lib/insika.rb; it is loaded in the registration block (wiring) on the 1st
10
+ # instance. One instance per remote agent (its own name/description).
11
+ class A2ARemote < RubyLLM::Tool
12
+ param :message, desc: "The message/task for the remote agent"
13
+
14
+ def initialize(client:, url:, tool_name:, description:, event_stream: nil)
15
+ @client = client
16
+ @url = url
17
+ @tool_name = tool_name
18
+ @description = description
19
+ @event_stream = event_stream
20
+ super()
21
+ end
22
+
23
+ # name/description per INSTANCE (RubyLLM derives them from the class; we override —
24
+ # otherwise the model would see "insika--tools--a2a_remote" for every remote).
25
+ def name = @tool_name
26
+ def description = @description
27
+
28
+ def execute(message:)
29
+ result = @client.call(@url, message.to_s)
30
+ emit(result)
31
+ result[:error] ? { error: result[:error] } : result[:text]
32
+ end
33
+
34
+ private
35
+
36
+ # :a2a_call without task correlation: registry tools do not receive the
37
+ # TurnState -> meta {} (like the Builder's :provider_warning). The :tool_call
38
+ # from wire_callbacks already correlates the call itself.
39
+ def emit(result)
40
+ @event_stream&.emit(Insika::Event.new(
41
+ type: :a2a_call,
42
+ data: { agent: @tool_name, remote_task_id: result[:id], state: result[:state] },
43
+ meta: {}
44
+ ))
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Tools
5
+ # Names the parent's subagent allowlist INSIDE the delegation tools' schema.
6
+ #
7
+ # The allowlist is per-turn data (`profile.subagents`), while a RubyLLM tool's
8
+ # schema is class-level, so the enum has to be injected per instance. Without
9
+ # it the model is told only that `agent` "must be one this agent may spawn" and
10
+ # is left to guess the ids; with it, the valid values are part of the contract
11
+ # the provider sees.
12
+ #
13
+ # Tolerant by construction: JSON-schema hashes reach us with symbol keys (the
14
+ # class-level `params(...)` form) or string keys (anything that round-tripped
15
+ # through JSON), and a shape we do not recognize is returned UNTOUCHED — a
16
+ # schema we failed to annotate still works, one we corrupted would break every
17
+ # delegation.
18
+ module AgentEnum
19
+ module_function
20
+
21
+ # schema: the tool's JSON schema · allowed: [String] · path: the property
22
+ # chain leading to the `agent` property, e.g. %i[agent] or %i[tasks agent].
23
+ # -> a COPY with `enum` set, or the original when there is nothing to do.
24
+ def inject(schema, allowed, path:)
25
+ return schema if schema.nil? || Array(allowed).empty?
26
+
27
+ copy = deep_dup(schema)
28
+ target = resolve(copy, path)
29
+ return schema if target.nil?
30
+
31
+ target[key_for(target, :enum)] = Array(allowed).map(&:to_s)
32
+ copy
33
+ end
34
+
35
+ # Walks `properties` (and `items` for an array) down to the named property.
36
+ def resolve(node, path)
37
+ path.reduce(node) do |current, segment|
38
+ props = fetch(current, :properties)
39
+ props = fetch(fetch(current, :items) || {}, :properties) if props.nil?
40
+ prop = props && (props[segment] || props[segment.to_s])
41
+ return nil unless prop.is_a?(Hash)
42
+
43
+ prop
44
+ end
45
+ end
46
+
47
+ def fetch(node, key)
48
+ return nil unless node.is_a?(Hash)
49
+
50
+ node[key] || node[key.to_s]
51
+ end
52
+
53
+ # Keep the hash's own key convention instead of mixing symbols into a
54
+ # string-keyed schema.
55
+ def key_for(hash, key)
56
+ hash.keys.any? { |k| k.is_a?(String) } ? key.to_s : key
57
+ end
58
+
59
+ def deep_dup(value)
60
+ case value
61
+ when Hash then value.each_with_object({}) { |(k, v), acc| acc[k] = deep_dup(v) }
62
+ when Array then value.map { |v| deep_dup(v) }
63
+ else value
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -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 (RFC-0010 §B1): 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 (item 30). 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