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,518 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+
6
+ module Insika
7
+ # Definition of a DATA-DEFINED TOOL (no Ruby code): name, description, parameters
8
+ # and an HTTP call. Immutable value object, persisted by ToolStore and
9
+ # materialized at runtime by Tools::DataDefinedTool (one class, N instances —
10
+ # the same pattern as A2ARemote).,; parameters migrated to JSON
11
+ # Schema in,.
12
+ #
13
+ # Persisted form (JSON-serializable Hash; ConfigStore stringifies the keys):
14
+ # { "name", "description",
15
+ # "parameters" => <JSON Schema>, # { "type":"object", "properties":{…}, "required":[…] }
16
+ # "request" => { "method","url","headers"=>{},"query"=>{},"body" },
17
+ # "response" => { "extract","path" },
18
+ # "secret_headers" => [ "Authorization", ... ],
19
+ # "side_effect" => bool, "timeout" => int|nil,
20
+ # "group" => string|nil, "tags" => ["b2b",...] } #//
21
+ #
22
+ # `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP):
23
+ # a nestable object, fed straight into RubyLLM's `params_schema` (provider-
24
+ # agnostic). The **flat array** (`[{name,type,required}]`) is SUGAR for the simple
25
+ # case: it is lifted to JSON Schema at build time. The sugar covers scalars and
26
+ # `array:<scalar>` — it CANNOT express an array of objects, and says so instead of
27
+ # guessing an item type. Ingestion validates a **safe subset** of JSON Schema (R1):
28
+ # it rejects composition (oneOf/anyOf/allOf/$ref/…) that not every provider supports.
29
+ #
30
+ # Validation lives HERE (single source): `build`/`from_h` raise ValidationError
31
+ # on malformed input. Name uniqueness and collision with a code tool are NOT
32
+ # validated here (the value object does not know the registry) — that belongs to the
33
+ # overlay. Secrets (credential headers) are the ToolStore's responsibility
34
+ # (masks/reconciles); the definition itself is agnostic to masking.
35
+ ToolDefinition = Data.define(
36
+ :name, :description, :parameters, :request, :response,
37
+ :secret_headers, :side_effect, :timeout, :group, :tags, :halt_when
38
+ )
39
+
40
+ class ToolDefinition
41
+ # Flat-sugar types: the SCALARS, plus `array:<scalar>` for a list. There is no bare
42
+ # `array`: an array without an item type is an INCOMPLETE declaration, and the
43
+ # engine refuses to guess one (see lift_flat_params).
44
+ PARAM_TYPES = %w[string number integer boolean].freeze
45
+ ARRAY_SUGAR_RE = /\Aarray:(string|number|integer|boolean)\z/
46
+ ARRAY_SUGAR = PARAM_TYPES.map { |t| "array:#{t}" }.freeze
47
+ HTTP_METHODS = %w[GET HEAD POST PUT PATCH DELETE].freeze
48
+ IDEMPOTENT = %w[GET HEAD].freeze # side_effect default = false
49
+ EXTRACTS = %w[body_raw status json_path].freeze
50
+ NAME_RE = /\A[a-z][a-z0-9_]*\z/ # identifier for the model
51
+ # A `.` in the placeholder enables the turn-context namespace `{{ctx.*}}`
52
+ # separate from the model's `{{param}}`. Params follow NAME_RE (no
53
+ # dot) -> a placeholder with a dot can only be a ctx ref.
54
+ PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/
55
+ # Turn-context namespace: values coming from the TURN (not the model),
56
+ # resolved by DataDefinedTool. Closed allowlist (a typo becomes a validation
57
+ # error, not a silently empty header).
58
+ CTX_PREFIX = "ctx."
59
+ CTX_FIELDS = %w[chat_id store_id agent_id tenant].freeze
60
+
61
+ # ---- safe subset of JSON Schema (R1) --------------------------------------
62
+ # Types supported by EVERY provider (OpenAI/Anthropic/Gemini/DeepSeek/Bedrock).
63
+ SCHEMA_TYPES = %w[object array string number integer boolean].freeze
64
+ # Composition/ref constructs that are NOT universally supported -> a clear error
65
+ # at ingestion time (instead of an opaque failure in the provider).
66
+ FORBIDDEN_KEYWORDS = %w[
67
+ oneOf anyOf allOf not $ref if then else
68
+ patternProperties dependencies dependentSchemas
69
+ propertyNames unevaluatedProperties $defs definitions
70
+ ].freeze
71
+
72
+ # Builds + validates. Raises Insika::ValidationError. Accepts keyword args
73
+ # (already-normalized symbol keys); use from_h for a raw Hash from the store/UI.
74
+ # `parameters` accepts JSON Schema (Hash) OR the legacy flat array.
75
+ def self.build(name:, description:, request:, parameters: nil, response: nil,
76
+ secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
77
+ halt_when: nil)
78
+ name = name.to_s
79
+ raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)
80
+
81
+ desc = description.to_s
82
+ raise Insika::ValidationError, "description is required" if desc.empty?
83
+
84
+ schema = normalize_params(parameters)
85
+ req = normalize_request(request, top_level_names(schema))
86
+ resp = normalize_response(response)
87
+
88
+ method = req[:method]
89
+ effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)
90
+
91
+ new(
92
+ name: name, description: desc, parameters: schema, request: req, response: resp,
93
+ secret_headers: Array(secret_headers).map(&:to_s), side_effect: effect,
94
+ timeout: timeout.nil? ? nil : Integer(timeout),
95
+ group: normalize_group(group), tags: normalize_tags(tags),
96
+ halt_when: normalize_halt_when(halt_when)
97
+ )
98
+ end
99
+
100
+ # Raw Hash (string or symbol keys, from the store/payload) -> ToolDefinition.
101
+ def self.from_h(hash)
102
+ h = deep_symbolize(hash)
103
+ build(
104
+ name: h[:name], description: h[:description], parameters: h[:parameters],
105
+ request: h[:request] || {}, response: h[:response],
106
+ secret_headers: h[:secret_headers], side_effect: h[:side_effect], timeout: h[:timeout],
107
+ group: h[:group], tags: h[:tags], halt_when: h[:halt_when]
108
+ )
109
+ end
110
+
111
+ # Group: enablement label by DATA (not name convention),
112
+ # target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
113
+ def self.normalize_group(group)
114
+ g = group.to_s.strip
115
+ g.empty? ? nil : g
116
+ end
117
+ private_class_method :normalize_group
118
+
119
+ # Free-form tags (metadata/discovery). List of non-empty, unique strings.
120
+ def self.normalize_tags(tags)
121
+ Array(tags).map { |t| t.to_s.strip }.reject(&:empty?).uniq
122
+ end
123
+ private_class_method :normalize_tags
124
+
125
+ # ---- parameter validation/normalization (class-private) -------------------
126
+
127
+ # Input (Hash=JSON Schema | Array=flat sugar | nil) -> canonical string-keyed
128
+ # JSON Schema, validated against the safe subset.
129
+ def self.normalize_params(params)
130
+ schema =
131
+ case params
132
+ when nil then empty_schema
133
+ when Array then lift_flat_params(params)
134
+ when Hash then deep_stringify(params)
135
+ else raise Insika::ValidationError, "parameters must be JSON Schema (object) or a list of params"
136
+ end
137
+
138
+ schema = coerce_object_schema(schema)
139
+ validate_schema!(schema, path: "parameters")
140
+ validate_top_level_names!(schema)
141
+ schema
142
+ end
143
+ private_class_method :normalize_params
144
+
145
+ def self.empty_schema = { "type" => "object", "properties" => {}, "required" => [] }
146
+ private_class_method :empty_schema
147
+
148
+ # Flat sugar -> JSON Schema. Preserves validation (NAME_RE, duplicates,
149
+ # PARAM_TYPES) and lifts `array:<scalar>` into proper `items`.
150
+ #
151
+ # A bare `array` is REFUSED. It used to lift to `items: {type:"string"}` — the
152
+ # engine inventing half the contract. That default is invisible in the authoring
153
+ # UI and silently correct-looking, so an array-of-OBJECTS param (the common shape:
154
+ # `[{query, filters}]`) reached the provider declared as an array of STRINGS. The
155
+ # model then obeyed the schema it was given, the backend answered 200, and the
156
+ # results were garbage — a failure with no error anywhere. The JSON Schema path
157
+ # already refuses `array` without `items` (validate_array!); the sugar now agrees.
158
+ def self.lift_flat_params(list)
159
+ seen = {}
160
+ properties = {}
161
+ required = []
162
+ Array(list).each do |p|
163
+ p = deep_symbolize(p)
164
+ pname = p[:name].to_s
165
+ raise Insika::ValidationError, "param name must match #{NAME_RE.inspect}" unless NAME_RE.match?(pname)
166
+ raise Insika::ValidationError, "param '#{pname}' duplicated" if seen[pname]
167
+
168
+ seen[pname] = true
169
+ prop = flat_property(pname, (p[:type] || "string").to_s)
170
+ prop["description"] = p[:description].to_s unless p[:description].to_s.empty?
171
+ properties[pname] = prop
172
+ required << pname if p.fetch(:required, true)
173
+ end
174
+ { "type" => "object", "properties" => properties, "required" => required }
175
+ end
176
+ private_class_method :lift_flat_params
177
+
178
+ # One flat type -> the property schema. Raises on a bare `array` with the spelling
179
+ # that fixes it, and on anything else unknown.
180
+ def self.flat_property(pname, type)
181
+ if (m = ARRAY_SUGAR_RE.match(type))
182
+ { "type" => "array", "items" => { "type" => m[1] } }
183
+ elsif PARAM_TYPES.include?(type)
184
+ { "type" => type }
185
+ elsif type == "array"
186
+ raise Insika::ValidationError,
187
+ "param '#{pname}': type 'array' needs an item type — use #{ARRAY_SUGAR.join('/')} " \
188
+ "for a list of scalars, or declare the full JSON Schema for a list of objects"
189
+ else
190
+ raise Insika::ValidationError,
191
+ "param '#{pname}': invalid type #{type.inspect} (#{(PARAM_TYPES + ARRAY_SUGAR).join('/')})"
192
+ end
193
+ end
194
+ private_class_method :flat_property
195
+
196
+ # The top level is always an object (the model always sends an args object). A Hash
197
+ # without "type" but with "properties" is assumed to be an object; any other top-level
198
+ # type is an error.
199
+ def self.coerce_object_schema(schema)
200
+ s = schema.dup
201
+ s["type"] ||= "object" if s.key?("properties") || !s.key?("type")
202
+ unless s["type"].to_s == "object"
203
+ raise Insika::ValidationError, "parameters (top) must be type object, not #{s['type'].inspect}"
204
+ end
205
+
206
+ s["properties"] ||= {}
207
+ s["required"] ||= []
208
+ s
209
+ end
210
+ private_class_method :coerce_object_schema
211
+
212
+ # Recursively validates the safe subset: type ∈ SCHEMA_TYPES, no composition/ref
213
+ # constructs, an object recurses into its properties, an array requires items.
214
+ def self.validate_schema!(node, path:)
215
+ raise Insika::ValidationError, "#{path}: schema must be a JSON Schema object" unless node.is_a?(Hash)
216
+
217
+ forbidden = node.keys.map(&:to_s) & FORBIDDEN_KEYWORDS
218
+ unless forbidden.empty?
219
+ raise Insika::ValidationError,
220
+ "#{path}: unsupported construct (#{forbidden.join(', ')}); safe subset: #{SCHEMA_TYPES.join('/')}/enum"
221
+ end
222
+
223
+ type = node["type"].to_s
224
+ raise Insika::ValidationError, "#{path}: 'type' is required" if type.empty?
225
+ raise Insika::ValidationError, "#{path}: invalid type #{node['type'].inspect}" unless SCHEMA_TYPES.include?(type)
226
+
227
+ case type
228
+ when "object" then validate_object!(node, path)
229
+ when "array" then validate_array!(node, path)
230
+ end
231
+
232
+ validate_enum!(node, path)
233
+ end
234
+ private_class_method :validate_schema!
235
+
236
+ def self.validate_object!(node, path)
237
+ props = node["properties"] || {}
238
+ raise Insika::ValidationError, "#{path}: 'properties' must be an object" unless props.is_a?(Hash)
239
+
240
+ props.each { |pname, pschema| validate_schema!(pschema, path: "#{path}.#{pname}") }
241
+
242
+ required = node["required"] || []
243
+ raise Insika::ValidationError, "#{path}: 'required' must be a list" unless required.is_a?(Array)
244
+
245
+ unknown = required.map(&:to_s) - props.keys.map(&:to_s)
246
+ raise Insika::ValidationError, "#{path}: required cites nonexistent property: #{unknown.join(', ')}" unless unknown.empty?
247
+ end
248
+ private_class_method :validate_object!
249
+
250
+ def self.validate_array!(node, path)
251
+ items = node["items"]
252
+ raise Insika::ValidationError, "#{path}: array requires 'items'" if items.nil?
253
+
254
+ validate_schema!(items, path: "#{path}[]")
255
+ end
256
+ private_class_method :validate_array!
257
+
258
+ def self.validate_enum!(node, path)
259
+ return unless node.key?("enum")
260
+
261
+ enum = node["enum"]
262
+ raise Insika::ValidationError, "#{path}: 'enum' must be a non-empty list" unless enum.is_a?(Array) && !enum.empty?
263
+ end
264
+ private_class_method :validate_enum!
265
+
266
+ # TOP-LEVEL property names are both model args AND {{placeholder}} targets ->
267
+ # they require NAME_RE (no dot, lowercase). Nested properties may be free-form.
268
+ def self.validate_top_level_names!(schema)
269
+ (schema["properties"] || {}).each_key do |pname|
270
+ raise Insika::ValidationError, "top-level param '#{pname}' must match #{NAME_RE.inspect}" unless NAME_RE.match?(pname.to_s)
271
+ end
272
+ end
273
+ private_class_method :validate_top_level_names!
274
+
275
+ def self.top_level_names(schema) = (schema["properties"] || {}).keys.map(&:to_s)
276
+ private_class_method :top_level_names
277
+
278
+ # ---- request/response validation/normalization (class-private) ------------
279
+
280
+ def self.normalize_request(request, param_names)
281
+ r = deep_symbolize(request)
282
+ method = (r[:method] || "GET").to_s.upcase
283
+ raise Insika::ValidationError, "invalid method #{method.inspect}" unless HTTP_METHODS.include?(method)
284
+
285
+ url = r[:url].to_s
286
+ raise Insika::ValidationError, "url is required" if url.empty?
287
+
288
+ # The URL is a TEMPLATE: {{x}} is not a valid URI character. Validate against a
289
+ # probe with the placeholders swapped for a safe token.
290
+ probe = url.gsub(PLACEHOLDER_RE, "x")
291
+ uri = begin
292
+ URI.parse(probe)
293
+ rescue URI::InvalidURIError
294
+ raise Insika::ValidationError, "invalid url"
295
+ end
296
+ raise Insika::ValidationError, "url must be http/https" unless %w[http https].include?(uri.scheme)
297
+
298
+ headers = stringify_values(r[:headers])
299
+ query = stringify_values(r[:query])
300
+ body = r[:body].nil? ? nil : r[:body].to_s
301
+
302
+ check_placeholders!([url, *headers.values, *query.values, body].compact, param_names)
303
+
304
+ { method: method, url: url, headers: headers, query: query, body: body }
305
+ end
306
+ private_class_method :normalize_request
307
+
308
+ # HALT CONDITION (optional): when the tool's RESPONSE says the turn is already
309
+ # answered, the model must not speak again. The classic case is a backend that
310
+ # performs the side effect AND sends its own confirmation to the customer: with
311
+ # the model free to comment, the person gets the message twice.
312
+ #
313
+ # "halt_when" => { "json_path" => "tool_result.status", "equals" => ["SUBSCRIBED"] }
314
+ #
315
+ # By RESULT, not by tool: the same call that halts on SUBSCRIBED must let the
316
+ # model explain a SUBSCRIPTION_FAILED. Evaluated against the parsed response
317
+ # body (independent of `response.extract`, which shapes what the MODEL sees) —
318
+ # a non-JSON body simply never matches. `equals` is compared as strings: JSON
319
+ # gives no type guarantee across backends and a status is a label, not a number.
320
+ # -> { json_path:, equals: [String] } | nil
321
+ def self.normalize_halt_when(halt_when)
322
+ return nil if halt_when.nil?
323
+
324
+ h = deep_symbolize(halt_when)
325
+ path = h[:json_path].to_s
326
+ raise Insika::ValidationError, "halt_when requires json_path" if path.empty?
327
+
328
+ values = Array(h[:equals]).map(&:to_s)
329
+ raise Insika::ValidationError, "halt_when requires a non-empty equals list" if values.empty?
330
+
331
+ { json_path: path, equals: values, say: normalize_halt_say(h[:say]) }.compact
332
+ end
333
+ private_class_method :normalize_halt_when
334
+
335
+ # `say` is EITHER a literal or a path, never both — two answers to "what does the
336
+ # customer get" is a configuration nobody can read. Refused at load rather than
337
+ # resolved by precedence: a silently ignored half would publish the wrong one.
338
+ def self.normalize_halt_say(say)
339
+ return nil if say.nil?
340
+
341
+ s = deep_symbolize(say)
342
+ text = s[:text].nil? ? nil : s[:text].to_s
343
+ path = s[:json_path].nil? ? nil : s[:json_path].to_s
344
+ given = [text, path].compact.reject(&:empty?)
345
+ if given.length != 1
346
+ raise Insika::ValidationError,
347
+ "halt_when.say takes exactly one of 'text' or 'json_path' (got #{given.length})"
348
+ end
349
+
350
+ text.nil? || text.empty? ? { json_path: path } : { text: text }
351
+ end
352
+ private_class_method :normalize_halt_say
353
+
354
+ def self.normalize_response(response)
355
+ r = deep_symbolize(response || {})
356
+ extract = (r[:extract] || "body_raw").to_s
357
+ raise Insika::ValidationError, "invalid extract #{extract.inspect}" unless EXTRACTS.include?(extract)
358
+
359
+ path = r[:path].nil? ? nil : r[:path].to_s
360
+ raise Insika::ValidationError, "extract 'json_path' requires path" if extract == "json_path" && (path.nil? || path.empty?)
361
+
362
+ { extract: extract, path: path }
363
+ end
364
+ private_class_method :normalize_response
365
+
366
+ # Every {{x}} in the templates must reference a declared TOP-LEVEL parameter OR a
367
+ # known turn-context field ({{ctx.chat_id}} etc.). The ctx refs are
368
+ # NOT model parameters — they are resolved per-turn by the engine.
369
+ def self.check_placeholders!(strings, param_names)
370
+ used = strings.flat_map { |s| s.to_s.scan(PLACEHOLDER_RE).flatten }.uniq
371
+ ctx_refs, params = used.partition { |u| u.start_with?(CTX_PREFIX) }
372
+
373
+ unknown_ctx = ctx_refs.reject { |u| CTX_FIELDS.include?(u.delete_prefix(CTX_PREFIX)) }
374
+ unless unknown_ctx.empty?
375
+ raise Insika::ValidationError,
376
+ "unknown turn context: #{unknown_ctx.join(', ')} " \
377
+ "(available: #{CTX_FIELDS.map { |f| CTX_PREFIX + f }.join(', ')})"
378
+ end
379
+
380
+ unknown = params - param_names
381
+ raise Insika::ValidationError, "placeholder(s) without a parameter: #{unknown.join(', ')}" unless unknown.empty?
382
+ end
383
+ private_class_method :check_placeholders!
384
+
385
+ def self.stringify_values(hash)
386
+ (deep_symbolize(hash || {})).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
387
+ end
388
+ private_class_method :stringify_values
389
+
390
+ def self.deep_symbolize(obj)
391
+ case obj
392
+ when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = deep_symbolize(v) }
393
+ when Array then obj.map { |v| deep_symbolize(v) }
394
+ else obj
395
+ end
396
+ end
397
+ private_class_method :deep_symbolize
398
+
399
+ # Canonical JSON-clean: keys AND symbols become strings (JSON has no symbol).
400
+ def self.deep_stringify(obj) = Insika::Coercion.deep_stringify(obj)
401
+ private_class_method :deep_stringify
402
+
403
+ # ---- instance -------------------------------------------------------------
404
+
405
+ # String-keyed Hash for persistence (ConfigStore stringifies again, but we
406
+ # normalize here so the record is stable across backends).
407
+ def to_h
408
+ {
409
+ "name" => name, "description" => description,
410
+ "parameters" => parameters,
411
+ "request" => request.transform_keys(&:to_s),
412
+ "response" => response.transform_keys(&:to_s),
413
+ "secret_headers" => secret_headers,
414
+ "side_effect" => side_effect, "timeout" => timeout,
415
+ "group" => group, "tags" => tags,
416
+ "halt_when" => halt_when&.transform_keys(&:to_s)
417
+ }
418
+ end
419
+
420
+ # -> true when this response ENDS the turn (no further model call). `body` is the
421
+ # raw response body; a parse failure or a missing path means "does not halt" —
422
+ # never end a turn on a guess.
423
+ def halt?(body)
424
+ return false if halt_when.nil?
425
+
426
+ parsed = JSON.parse(body.to_s)
427
+ value = dig_path(parsed, halt_when[:json_path])
428
+ return false if value == PATH_MISS
429
+
430
+ halt_when[:equals].include?(value.to_s)
431
+ rescue JSON::ParserError
432
+ false
433
+ end
434
+
435
+ # WHAT THE CUSTOMER GETS WHEN THE MODEL SAID NOTHING FIRST (`halt_when.say`).
436
+ #
437
+ # A halt is worth the model's lead-in ("vou te conectar agora") and nothing
438
+ # after — but the model does not always write one, and then the turn published
439
+ # an EMPTY answer. Measured on a real store: two escalation turns in a row
440
+ # delivered silence, where the same agent without the halt at least said "o time
441
+ # de suporte já está cuidando do seu caso".
442
+ #
443
+ # The value cannot be guessed. `json_path` + `equals` cannot supply it either:
444
+ # the matched value is by definition one of the `equals` tokens, so publishing
445
+ # it would ship "SUBSCRIBED" to a person as often as it ships a sentence. So the
446
+ # operator names it, in one of two shapes:
447
+ #
448
+ # "say" => { "json_path" => "tool_result" } # the sentence the backend returned
449
+ # "say" => { "text" => "CALL_SUPPORT" } # a literal the CHANNEL resolves
450
+ #
451
+ # The literal form is the one that replaces the usual workaround — forcing the
452
+ # prompt to emit a control token and parsing it downstream. The token then comes
453
+ # from the tool's own contract, deterministically, instead of depending on the
454
+ # model complying with an instruction.
455
+ #
456
+ # -> String | nil. nil means "publish nothing", which is the pre-existing
457
+ # behaviour and stays the default for every tool that declares no `say`.
458
+ def halt_say(body)
459
+ say = halt_when && halt_when[:say]
460
+ return nil if say.nil?
461
+ return Coercion.presence(say[:text]) if say[:text]
462
+
463
+ parsed = JSON.parse(body.to_s)
464
+ value = dig_path(parsed, say[:json_path])
465
+ # Only a String is publishable: a hash or a number reaching a customer as the
466
+ # answer is never what someone meant.
467
+ value.is_a?(String) ? Coercion.presence(value) : nil
468
+ rescue JSON::ParserError
469
+ nil
470
+ end
471
+
472
+ # HOW `say` REACHES THE EXECUTOR. RubyLLM's `Tool::Halt` carries one value, and
473
+ # that value is the tool's payload (the trace records it, and the model never
474
+ # sees it — the halt ends the loop). So a halt that has something to publish
475
+ # carries BOTH, under keys distinctive enough that a trace reader knows what
476
+ # they are on sight. Unwrapped tools are untouched: no `say`, no wrapper.
477
+ SAY_KEY = "__insika_halt_say"
478
+ PAYLOAD_KEY = "__insika_halt_payload"
479
+
480
+ def self.wrap_halt(payload, say) = { SAY_KEY => say, PAYLOAD_KEY => payload }
481
+
482
+ # -> the text to publish, or nil when this halt carries none.
483
+ def self.halt_say_of(content)
484
+ content.is_a?(Hash) ? Coercion.presence(content[SAY_KEY]) : nil
485
+ end
486
+
487
+ # Walks a dotted path. Returns PATH_MISS (not nil) when a segment is absent, so a
488
+ # key whose stored value IS nil stays distinguishable from a missing key.
489
+ PATH_MISS = Object.new.freeze
490
+
491
+ def dig_path(parsed, path)
492
+ path.to_s.split(".").reduce(parsed) do |cur, seg|
493
+ return PATH_MISS unless cur.is_a?(Hash) && cur.key?(seg)
494
+
495
+ cur[seg]
496
+ end
497
+ end
498
+ private :dig_path
499
+
500
+ # Names of the required top-level parameters (DataDefinedTool validates presence
501
+ # before the call). Derived from the JSON Schema's `required`.
502
+ def required_params = Array(parameters["required"]).map(&:to_s)
503
+
504
+ # FLAT view of the top-level properties (name/type/description/required) — for
505
+ # RubyLLM's `#parameters` (discovery/tool_search) and the simple authoring UI.
506
+ # The full nested schema goes through `params_schema` (DataDefinedTool). Symbol-
507
+ # keyed for compat with callers that already consumed the flat params.
508
+ def top_level_params
509
+ props = parameters["properties"] || {}
510
+ required = required_params
511
+ props.map do |pname, pschema|
512
+ pschema ||= {}
513
+ { name: pname.to_s, type: (pschema["type"] || "string").to_s,
514
+ description: pschema["description"].to_s, required: required.include?(pname.to_s) }
515
+ end
516
+ end
517
+ end
518
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "delegate"
5
+ require "time"
6
+
7
+ module Insika
8
+ # Wraps each allowed tool: per-call timeout
9
+ # + recording of a non-idempotent side-effect BEFORE the result returns to the
10
+ # model. Delegates everything else (name/description/params) to the real tool.
11
+ #
12
+ # The tool loop belongs to RubyLLM; this is a decorator over the instances —
13
+ # the Executor never drives roundtrips.
14
+ class ToolEnvelope < SimpleDelegator
15
+ # The tool timeout's OWN class: distinct from Async::TimeoutError so that
16
+ # the rescue below NEVER swallows the TURN timeout (which uses the default of
17
+ # with_timeout). Without this, a turn overflowing while the fiber is inside a
18
+ # tool would be masked as a tool timeout and the turn would run past the
19
+ # deadline (a durability defect).
20
+ ToolTimeout = Class.new(StandardError)
21
+ private_constant :ToolTimeout
22
+
23
+ def initialize(tool, state:, checkpoint_store:, tool_registry:, timeout:,
24
+ skip_side_effects: [], trace_recorder: nil)
25
+ super(tool)
26
+ @state = state
27
+ @checkpoint_store = checkpoint_store
28
+ @tool_registry = tool_registry
29
+ @timeout = timeout
30
+ @skip_side_effects = Array(skip_side_effects) # ids already completed in the interrupted turn
31
+ @trace_recorder = trace_recorder # duck-type: #record(session_id:, entry:). nil = no trace.
32
+ end
33
+
34
+ # Entry point that RubyLLM invokes (Tool#call in the pinned version).
35
+ # A timeout overflow returns to the MODEL as a serialized error — it does
36
+ # not bring down the turn.
37
+ def call(args)
38
+ # A non-idempotent tool call ALREADY COMPLETED in the interrupted
39
+ # turn -> respond with a marker, NEVER re-execute. The marker returns to
40
+ # the model, keeping the tool-use protocol intact.
41
+ call_id = correlation_id
42
+ return { "skipped" => "already_executed" } if call_id && @skip_side_effects.include?(call_id)
43
+
44
+ # Approval gate: a tool marked `approval` suspends the turn in
45
+ # :waiting until the operator resolves it. Delegates to the coordinator (the
46
+ # Executor), which creates/queries the PendingAction and blocks via the
47
+ # mailbox. Rejection returns to the MODEL as an error (the turn continues),
48
+ # it does not bring down the turn. CancelledError/TimeoutError from the wait
49
+ # propagate (they are not ToolTimeout).
50
+ if approval_required?
51
+ decision = @state.approval_coordinator.request_approval(
52
+ task: @state.task, turn: @state.turn, tool: real_name, args: args, actor: @state.actor
53
+ )
54
+ return { error: "rejected by operator" } unless decision.to_s == "approved"
55
+ end
56
+
57
+ started = monotonic
58
+ result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { __getobj__.call(args) } }
59
+ record_side_effect!(call_id) if side_effect?
60
+ trace(call_id, args, result, started)
61
+ result
62
+ rescue ToolTimeout
63
+ err = { error: "TimeoutError: tool exceeded #{@timeout}s" }
64
+ trace(call_id, args, err, started)
65
+ err
66
+ end
67
+
68
+ private
69
+
70
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
71
+
72
+ # with parallel tool calls on, the turn's shared semaphore
73
+ # (TurnState#tool_gate, sized by `limits[:tool_concurrency]`) caps how many run
74
+ # at once. Wraps the REAL call ONLY — the approval wait and the skip check are
75
+ # outside it, so a call blocked on a human never holds a slot, and the per-call
76
+ # `tool_timeout` clock starts after the slot is granted rather than while
77
+ # queueing for one. The trace's `ms` DOES include the queue wait: that is the
78
+ # wall-clock the model waited. No gate (the default, serial) = straight through.
79
+ def with_gate(&)
80
+ gate = @state.respond_to?(:tool_gate) ? @state.tool_gate : nil
81
+ gate ? gate.acquire(&) : yield
82
+ end
83
+
84
+ # Records the call for debugging in the Studio (name + model args + result +
85
+ # ms), keyed by the SESSION. Masking/truncation is the ToolTraceStore's job;
86
+ # here we only collect. NEVER breaks the turn (trace is observability).
87
+ def trace(call_id, args, result, started)
88
+ return unless @trace_recorder && @state.task&.session_id
89
+
90
+ @trace_recorder.record(
91
+ session_id: @state.task.session_id,
92
+ entry: { "turn" => @state.turn, "tool" => real_name, "call_id" => call_id.to_s,
93
+ "args" => args, "result" => result,
94
+ "ms" => started ? ((monotonic - started) * 1000).round : nil,
95
+ "at" => Time.now.utc.iso8601 }
96
+ )
97
+ rescue StandardError
98
+ nil
99
+ end
100
+
101
+ # The real impl_name when the delegate is a Capability::ResolvedTool:
102
+ # side_effect?/approval/correlation operate on the REAL name registered in
103
+ # the tool_registry (the capability alias does not exist there). A direct
104
+ # tool = #name.
105
+ def real_name
106
+ __getobj__.respond_to?(:impl_name) ? __getobj__.impl_name.to_s : __getobj__.name.to_s
107
+ end
108
+
109
+ # Does the current tool require approval? (names come from the Resolution
110
+ # via state).
111
+ def approval_required?
112
+ @state.respond_to?(:requires_approval) &&
113
+ Array(@state.requires_approval).include?(real_name)
114
+ end
115
+
116
+ # The call's correlation: the provider id (RubyLLM chat, via
117
+ # before_tool_call) when it exists; otherwise the tool NAME — the workflow
118
+ # case, which calls the instances directly and has no provider-generated id.
119
+ # LIMITATION: name-based correlation is per-TOOL, not per-call. If a
120
+ # workflow calls the SAME side-effect tool more than once in a turn,
121
+ # the resume skips ALL calls of that name (over-skip) — per-step
122
+ # checkpointing is future work. One call per tool is safe.
123
+ def correlation_id
124
+ (@state.current_tool_call&.id || real_name).to_s
125
+ end
126
+
127
+ def side_effect?
128
+ @tool_registry.respond_to?(:side_effect?) &&
129
+ @tool_registry.side_effect?(real_name)
130
+ end
131
+
132
+ # Written BEFORE the tool result returns to the model.
133
+ def record_side_effect!(call_id)
134
+ return if call_id.to_s.empty?
135
+
136
+ @checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
137
+ tool_call_id: call_id)
138
+ end
139
+ end
140
+ end