insika 0.8.0 → 1.0.1

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -20,7 +20,7 @@ module Insika
20
20
  # "group" => string|nil, "tags" => ["b2b",...] } #//
21
21
  #
22
22
  # `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP):
23
- # a nestable object, fed straight into RubyLLM's `params_schema` (provider-
23
+ # a nestable object, fed straight into RubyLLM's `parameters_schema` (provider-
24
24
  # agnostic). The **flat array** (`[{name,type,required}]`) is SUGAR for the simple
25
25
  # case: it is lifted to JSON Schema at build time. The sugar covers scalars and
26
26
  # `array:<scalar>` — it CANNOT express an array of objects, and says so instead of
@@ -35,10 +35,17 @@ module Insika
35
35
  ToolDefinition = Data.define(
36
36
  :name, :description, :parameters, :request, :response,
37
37
  :secret_headers, :side_effect, :timeout, :group, :tags, :halt_when,
38
- :evidence # Insika::Evidence::Spec | nil
38
+ :evidence, # Insika::Evidence::Spec | nil
39
+ :requires_evidence, # { "params" => [String] } | nil
40
+ :presentation # { component:, ids:, max: } | nil — a UI tool, no HTTP
39
41
  )
40
42
 
41
43
  class ToolDefinition
44
+ # Terminal tool results finish the current batch without another model request.
45
+ Halt = Data.define(:content) do
46
+ def to_s = content.to_s
47
+ end
48
+
42
49
  # Flat-sugar types: the SCALARS, plus `array:<scalar>` for a list. There is no bare
43
50
  # `array`: an array without an item type is an INCOMPLETE declaration, and the
44
51
  # engine refuses to guess one (see lift_flat_params).
@@ -73,9 +80,9 @@ module Insika
73
80
  # Builds + validates. Raises Insika::ValidationError. Accepts keyword args
74
81
  # (already-normalized symbol keys); use from_h for a raw Hash from the store/UI.
75
82
  # `parameters` accepts JSON Schema (Hash) OR the legacy flat array.
76
- def self.build(name:, description:, request:, parameters: nil, response: nil,
83
+ def self.build(name:, description:, request: nil, parameters: nil, response: nil,
77
84
  secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
78
- halt_when: nil, evidence: nil)
85
+ halt_when: nil, evidence: nil, presentation: nil, requires_evidence: nil)
79
86
  name = name.to_s
80
87
  raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)
81
88
 
@@ -83,15 +90,21 @@ module Insika
83
90
  raise Insika::ValidationError, "description is required" if desc.empty?
84
91
 
85
92
  schema = normalize_params(parameters)
86
- req = normalize_request(request, top_level_names(schema))
93
+ # A tool is EITHER an HTTP call or a presentation — never both, never neither.
94
+ if request && presentation
95
+ raise Insika::ValidationError, "a tool declares either 'request' or 'presentation', not both"
96
+ end
97
+
98
+ pres = normalize_presentation(presentation, schema)
99
+ req = pres ? nil : normalize_request(request || {}, top_level_names(schema))
87
100
  resp = normalize_response(response)
88
101
  if resp[:extract] == "evidence_envelope" && evidence.nil?
89
102
  raise Insika::ValidationError,
90
103
  "extract 'evidence_envelope' requires an 'evidence' declaration"
91
104
  end
92
105
 
93
- method = req[:method]
94
- effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)
106
+ method = req && req[:method]
107
+ effect = side_effect.nil? ? !(method.nil? || IDEMPOTENT.include?(method)) : (side_effect ? true : false)
95
108
 
96
109
  new(
97
110
  name: name, description: desc, parameters: schema, request: req, response: resp,
@@ -99,7 +112,9 @@ module Insika
99
112
  timeout: timeout.nil? ? nil : Integer(timeout),
100
113
  group: normalize_group(group), tags: normalize_tags(tags),
101
114
  halt_when: normalize_halt_when(halt_when),
102
- evidence: Insika::Evidence::Spec.parse(evidence)
115
+ evidence: Insika::Evidence::Spec.parse(evidence),
116
+ presentation: pres,
117
+ requires_evidence: normalize_requires_evidence(requires_evidence, top_level_names(schema))
103
118
  )
104
119
  end
105
120
 
@@ -108,12 +123,64 @@ module Insika
108
123
  h = deep_symbolize(hash)
109
124
  build(
110
125
  name: h[:name], description: h[:description], parameters: h[:parameters],
111
- request: h[:request] || {}, response: h[:response],
126
+ request: h[:request], response: h[:response],
112
127
  secret_headers: h[:secret_headers], side_effect: h[:side_effect], timeout: h[:timeout],
113
- group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence]
128
+ group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence],
129
+ presentation: h[:presentation], requires_evidence: h[:requires_evidence]
114
130
  )
115
131
  end
116
132
 
133
+ # PRESENTATION: a tool whose job is to SHOW something, not to fetch it. The model
134
+ # picks ids, the engine validates them against the session's evidence ledger and
135
+ # joins the cards an evidence tool already returned (Tools::Present). No HTTP.
136
+ #
137
+ # "presentation" => { "component" => "product_cards", # what the channel renders
138
+ # "ids" => "product_ids", # the array:string param
139
+ # "max" => 8 } # 1..16, the attachment cap
140
+ #
141
+ # -> { component:, ids:, max: } | nil
142
+ def self.normalize_presentation(raw, schema)
143
+ return nil if raw.nil?
144
+
145
+ h = deep_symbolize(raw)
146
+ raise Insika::ValidationError, "presentation must be an object" unless h.is_a?(Hash)
147
+
148
+ component = h[:component].to_s
149
+ unless NAME_RE.match?(component)
150
+ raise Insika::ValidationError, "presentation.component must match #{NAME_RE.inspect}"
151
+ end
152
+
153
+ ids = h[:ids].to_s
154
+ prop = (schema["properties"] || {})[ids]
155
+ unless prop.is_a?(Hash) && prop["type"] == "array" && prop.dig("items", "type") == "string"
156
+ raise Insika::ValidationError,
157
+ "presentation.ids must name a declared array:string parameter (got #{ids.inspect})"
158
+ end
159
+
160
+ max = h[:max].nil? ? Insika::Evidence::MAX_ATTACHMENTS : Integer(h[:max], exception: false)
161
+ unless max.is_a?(Integer) && max.between?(1, Insika::Evidence::MAX_ATTACHMENTS)
162
+ raise Insika::ValidationError, "presentation.max must be 1..#{Insika::Evidence::MAX_ATTACHMENTS}"
163
+ end
164
+
165
+ { component: component, ids: ids, max: max }
166
+ end
167
+ private_class_method :normalize_presentation
168
+
169
+ def presentation? = !presentation.nil?
170
+
171
+ # Public because an MCP tool has the same question to answer and there must be
172
+ # one reading of "these parameters may only carry an id some tool returned".
173
+ def self.normalize_requires_evidence(value, param_names)
174
+ return nil if value.nil?
175
+
176
+ params = value.is_a?(Hash) ? deep_symbolize(value)[:params] : value
177
+ unless params.is_a?(Array) && !params.empty? && params.all? { |p| param_names.include?(p.to_s) }
178
+ raise Insika::ValidationError, "requires_evidence needs a non-empty list of declared top-level parameters"
179
+ end
180
+
181
+ { "params" => params.map(&:to_s).uniq }
182
+ end
183
+
117
184
  # Group: enablement label by DATA (not name convention),
118
185
  # target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
119
186
  def self.normalize_group(group)
@@ -414,7 +481,7 @@ module Insika
414
481
  h = {
415
482
  "name" => name, "description" => description,
416
483
  "parameters" => parameters,
417
- "request" => request.transform_keys(&:to_s),
484
+ "request" => request&.transform_keys(&:to_s),
418
485
  "response" => response.transform_keys(&:to_s),
419
486
  "secret_headers" => secret_headers,
420
487
  "side_effect" => side_effect, "timeout" => timeout,
@@ -424,6 +491,8 @@ module Insika
424
491
  # present only when declared — a tool without evidence is byte-identical
425
492
  # to today (no declaration, no envelope processing).
426
493
  h["evidence"] = evidence.to_h if evidence
494
+ h["requires_evidence"] = requires_evidence if requires_evidence
495
+ h["presentation"] = presentation.transform_keys(&:to_s) if presentation
427
496
  h
428
497
  end
429
498
 
@@ -479,7 +548,7 @@ module Insika
479
548
  nil
480
549
  end
481
550
 
482
- # HOW `say` REACHES THE EXECUTOR. RubyLLM's `Tool::Halt` carries one value, and
551
+ # HOW `say` REACHES THE EXECUTOR. Insika::ToolDefinition::Halt carries one value, and
483
552
  # that value is the tool's payload (the trace records it, and the model never
484
553
  # sees it — the halt ends the loop). So a halt that has something to publish
485
554
  # carries BOTH, under keys distinctive enough that a trace reader knows what
@@ -513,7 +582,7 @@ module Insika
513
582
 
514
583
  # FLAT view of the top-level properties (name/type/description/required) — for
515
584
  # RubyLLM's `#parameters` (discovery/tool_search) and the simple authoring UI.
516
- # The full nested schema goes through `params_schema` (DataDefinedTool). Symbol-
585
+ # The full nested schema goes through `parameters_schema` (DataDefinedTool). Symbol-
517
586
  # keyed for compat with callers that already consumed the flat params.
518
587
  def top_level_params
519
588
  props = parameters["properties"] || {}
@@ -9,9 +9,29 @@ module Insika
9
9
  # + recording of a non-idempotent side-effect BEFORE the result returns to the
10
10
  # model. Delegates everything else (name/description/params) to the real tool.
11
11
  #
12
- # The tool loop belongs to RubyLLM; this is a decorator over the instances —
13
- # the Executor never drives roundtrips.
12
+ # RubyLLM executes tools; this decorator enforces Insika's per-call policy.
14
13
  class ToolEnvelope < SimpleDelegator
14
+ # Fiber-local slot where a tool leaves its timing breakdown for this call's trace.
15
+ TIMING_KEY = :insika_tool_timing
16
+
17
+ PROVENANCE_INSTRUCTION = "This value was not returned by any tool in this conversation. " \
18
+ "Find it with a tool that returns it — a search or a lookup by id — " \
19
+ "then call this tool again with an id from that result."
20
+
21
+ # The words the model reads when a call is held. They are the only thing
22
+ # standing between a correct store and a customer who was told the opposite:
23
+ # the engine can refuse the write, it cannot refuse the sentence. Measured —
24
+ # one reply in twenty announced a held purchase as closed — so the reply's
25
+ # SHAPE is stated before anything else, and the lie is named concretely
26
+ # rather than as "do not say it happened".
27
+ CONFIRMATION_INSTRUCTION = "NOT DONE — nothing was written, and nothing will be until the customer " \
28
+ "answers this very message. Your reply must ASK: say exactly what will " \
29
+ "happen, with these arguments, and end with the question. A reply that " \
30
+ "reports it as finished — 'done', 'closed', 'created', any past tense — is " \
31
+ "false, and the customer will act on it. On their next message: if they " \
32
+ "confirm, call confirm_pending with this pending_id; if they decline, " \
33
+ "change anything, or ask for something else, call cancel_pending."
34
+
15
35
  # The tool timeout's OWN class: distinct from Async::TimeoutError so that
16
36
  # the rescue below NEVER swallows the TURN timeout (which uses the default of
17
37
  # with_timeout). Without this, a turn overflowing while the fiber is inside a
@@ -20,48 +40,122 @@ module Insika
20
40
  ToolTimeout = Class.new(StandardError)
21
41
  private_constant :ToolTimeout
22
42
 
43
+ # RubyLLM asks the tool's CLASS whether to defer its definition (tool
44
+ # search), and a delegator's class is this one. No Insika tool defers, so
45
+ # every envelope is sent as an ordinary tool.
46
+ def self.deferred? = false
47
+
48
+ # A gate's refusal. A plain Hash subclass: it reaches the model exactly as the
49
+ # `{status:, gate:, ...}` it always was, and the engine's own readers (the
50
+ # :tool_result outcome, the trace) recognize a refusal by CLASS — a data tool
51
+ # answering `{"status":"blocked","gate":"fraud_review"}` for a held order is
52
+ # not one, whatever keys it happens to use.
53
+ class Blocked < Hash; end
54
+
55
+ # A call held for the customer's confirmation. Same Hash-subclass trick as
56
+ # Blocked, and a different class on purpose: nothing was refused and nothing
57
+ # ran — the next customer message decides, through confirm_pending.
58
+ class Held < Hash; end
59
+
23
60
  def initialize(tool, state:, checkpoint_store:, tool_registry:, timeout:,
24
- skip_side_effects: [], trace_recorder: nil)
61
+ skip_side_effects: [], trace_recorder: nil, event_stream: nil)
25
62
  super(tool)
26
63
  @state = state
27
64
  @checkpoint_store = checkpoint_store
28
65
  @tool_registry = tool_registry
29
66
  @timeout = timeout
30
- @skip_side_effects = Array(skip_side_effects) # ids already completed in the interrupted turn
67
+ @event_stream = event_stream
68
+ @skip_side_effects = Array(skip_side_effects).dup # ids already completed in the interrupted turn
31
69
  @trace_recorder = trace_recorder # duck-type: #record(session_id:, entry:). nil = no trace.
32
70
  end
33
71
 
34
72
  # Entry point that RubyLLM invokes (Tool#call in the pinned version).
35
73
  # A timeout overflow returns to the MODEL as a serialized error — it does
36
74
  # not bring down the turn.
37
- def call(args)
75
+ def call(args = {}, tool_call: nil, **kwargs)
76
+ @state.current_tool_call = tool_call if tool_call
77
+ run(args.merge(kwargs), hold: true, native_call: tool_call && native_approvals?)
78
+ end
79
+
80
+ def requires_approval? = native_approvals? && approval_required?
81
+
82
+ def approval_tool_name = real_name
83
+
84
+ # RubyLLM queries this while inspecting and partitioning pending calls.
85
+ # Keep it read-only; policy refusals still return through #call as tool results.
86
+ def approval_resolver
87
+ ->(call) do
88
+ next true if @skip_side_effects.include?(call.id.to_s) || provenance_block(call.arguments) || customer_confirm?
89
+
90
+ native_approval_decision(call.arguments, call.id)
91
+ end
92
+ end
93
+
94
+ # The confirmed re-run of a held call (Tools::ConfirmPending): every check but
95
+ # the hold itself — provenance, approval, fencing, evidence, the side-effect
96
+ # record and the trace all still apply to the write.
97
+ def call_confirmed(args) = run(args, hold: false)
98
+
99
+ private
100
+
101
+ def run(args, hold:, native_call: false)
38
102
  # A non-idempotent tool call ALREADY COMPLETED in the interrupted
39
103
  # turn -> respond with a marker, NEVER re-execute. The marker returns to
40
104
  # the model, keeping the tool-use protocol intact.
41
105
  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).
106
+ continuation = native_call && @state.approval_continuation
107
+ if call_id && @skip_side_effects.include?(call_id)
108
+ halt = continuation && continuation.dig("halts", call_id)
109
+ return ToolDefinition::Halt.new(content: halt["content"]) if halt
110
+
111
+ return { "skipped" => "already_executed" }
112
+ end
113
+
114
+ started = monotonic
115
+ if (blocked = provenance_block(args))
116
+ trace(call_id, args, blocked, started)
117
+ emit_blocked(blocked)
118
+ return blocked
119
+ end
120
+
121
+ # Customer confirmation: the call is recorded and returned to the model as
122
+ # held; the turn goes on to ask. After the provenance gate (an id the customer
123
+ # never saw is not put to them as real) and before approval.
124
+ if hold && (held = confirmation_hold(args))
125
+ trace(call_id, args, held, started)
126
+ return held
127
+ end
128
+
129
+ # Native calls recheck the persisted decision after RubyLLM's resolver.
130
+ # Direct/workflow calls still create and await approval through the mailbox.
131
+ # Cancellation and turn timeout propagate from that wait (not ToolTimeout).
50
132
  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"
133
+ approved = if native_call
134
+ native_approval_decision(args, call_id) == true
135
+ else
136
+ @state.approval_coordinator.request_approval(
137
+ task: @state.task, turn: @state.turn, tool: real_name, args: args, actor: @state.actor
138
+ ).to_s == "approved"
139
+ end
140
+ return { error: "rejected by operator" } unless approved
55
141
  end
56
142
 
57
143
  started = monotonic
58
- result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { __getobj__.call(args) } }
144
+ Thread.current[TIMING_KEY] = nil
145
+ result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { invoke(args) } }
59
146
  # the ONE seam every tool result passes on its way to the model.
60
147
  # For a declared-evidence tool: reshape to the lean envelope, record the ids
61
148
  # on the ledger, hoard the attachments. No evidence = the result passes
62
149
  # through untouched (one nil-check — parity).
150
+ capture_shared_result(call_id, result)
63
151
  result = process_evidence(result)
64
- record_side_effect!(call_id) if side_effect?
152
+ result = fence(result)
153
+ if side_effect?
154
+ halt = { "content" => result.content } if continuation && result.is_a?(ToolDefinition::Halt)
155
+ record_side_effect!(call_id, **(halt ? { halt: halt } : {}))
156
+ (continuation["halts"] ||= {})[call_id] = halt if halt
157
+ @skip_side_effects << call_id if native_call
158
+ end
65
159
  trace(call_id, args, result, started)
66
160
  result
67
161
  rescue ToolTimeout
@@ -70,10 +164,17 @@ module Insika
70
164
  err
71
165
  end
72
166
 
73
- private
74
-
75
167
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
168
 
169
+ def invoke(args)
170
+ tool = __getobj__
171
+ if tool.respond_to?(:parameters_schema)
172
+ tool.call(**args.transform_keys(&:to_sym), tool_call: @state.current_tool_call)
173
+ else
174
+ tool.call(args)
175
+ end
176
+ end
177
+
77
178
  # with parallel tool calls on, the turn's shared semaphore
78
179
  # (TurnState#tool_gate, sized by `limits[:tool_concurrency]`) caps how many run
79
180
  # at once. Wraps the REAL call ONLY — the approval wait and the skip check are
@@ -83,21 +184,120 @@ module Insika
83
184
  # wall-clock the model waited. No gate (the default, serial) = straight through.
84
185
  def with_gate(&)
85
186
  gate = @state.respond_to?(:tool_gate) ? @state.tool_gate : nil
86
- gate ? gate.acquire(&) : yield
187
+ return yield unless gate
188
+
189
+ serial = @state.respond_to?(:side_effect_gate) ? @state.side_effect_gate : nil
190
+ return gate.acquire(&) unless side_effect? && serial
191
+
192
+ # Backends may read-modify-write. Serialize writes before taking a slot,
193
+ # so queued writes cannot keep independent reads from running.
194
+ serial.acquire { gate.acquire(&) }
195
+ end
196
+
197
+ def provenance_block(args)
198
+ tool = __getobj__
199
+ requirement = tool.respond_to?(:requires_evidence) ? tool.requires_evidence : nil
200
+ return unless requirement
201
+
202
+ ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
203
+ known = ledger ? ledger.ids : []
204
+ optional = optional_params(tool)
205
+ requirement.fetch("params").each do |param|
206
+ value = args.key?(param) ? args[param] : args[param.to_sym]
207
+ # A parameter the schema marks optional and the model left out carries no
208
+ # id to ground — nothing to check (a REQUIRED one left out is still a
209
+ # block: the write would run without the id the gate exists for).
210
+ next if value.nil? && ledger && optional.include?(param)
211
+
212
+ values = value.is_a?(Array) ? value : [value]
213
+ values = [nil] if values.empty? && !ledger
214
+ values.each do |id|
215
+ next if ledger && known.include?(id.to_s)
216
+
217
+ return Blocked[{ "status" => "blocked", "gate" => "provenance", "param" => param,
218
+ "value" => id.to_s, "instruction" => PROVENANCE_INSTRUCTION }]
219
+ end
220
+ end
221
+ nil
222
+ end
223
+
224
+ # The wrapped tool's top-level parameters NOT in the schema's `required`
225
+ # (DataDefinedTool exposes its definition's schema; a code tool exposes none
226
+ # -> every declared parameter is treated as required).
227
+ def optional_params(tool)
228
+ return [] unless tool.respond_to?(:parameters_schema) && (schema = tool.parameters_schema).is_a?(Hash)
229
+
230
+ schema = Coercion.deep_stringify(schema)
231
+
232
+ (schema["properties"] || {}).keys.map(&:to_s) - Array(schema["required"]).map(&:to_s)
233
+ end
234
+
235
+ def confirmation_hold(args)
236
+ return unless customer_confirm?
237
+
238
+ store = @state.respond_to?(:pending_action_store) ? @state.pending_action_store : nil
239
+ task = @state.task
240
+ if store.nil? || task.nil? || task.session_id.nil?
241
+ # Fail loud, never run: a write that needs the customer's word and has
242
+ # nowhere to wait for it is a misconfiguration, not a pass.
243
+ missing = store.nil? ? "no PendingActionStore" : "no session"
244
+ raise Insika::Error, "tool '#{real_name}' needs the customer's confirmation but the turn has #{missing} to hold it in"
245
+ end
246
+
247
+ id = PendingActionStore.confirmation_id(task.session_id, real_name)
248
+ store.create(id: id, task_id: task.id, session_id: task.session_id, turn: @state.turn,
249
+ tool: real_name, args: args || {}, kind: PendingActionStore::CUSTOMER)
250
+ @event_stream&.emit(Insika::Event.new(
251
+ type: :confirmation_requested,
252
+ data: { pending_id: id, tool: real_name, args: args },
253
+ meta: { task_id: task.id, session_id: task.session_id }
254
+ ))
255
+ # Key order is what the model reads first: the verdict, then the instruction,
256
+ # then the echo of what it asked for — the echo alone reads like a success.
257
+ Held[{ "executed" => false, "status" => "pending_confirmation", "gate" => "confirmation",
258
+ "instruction" => CONFIRMATION_INSTRUCTION, "tool" => real_name, "args" => args,
259
+ "pending_id" => id }]
260
+ end
261
+
262
+ def customer_confirm?
263
+ profile = @state.respond_to?(:profile) ? @state.profile : nil
264
+ list = profile.respond_to?(:customer_confirm) ? profile.customer_confirm : nil
265
+ Array(list).include?(real_name)
266
+ end
267
+
268
+ def emit_blocked(result)
269
+ task = @state.task # nil on a one-shot turn, like `trace` already assumes
270
+ @event_stream&.emit(Insika::Event.new(
271
+ type: :tool_blocked,
272
+ data: { name: real_name, gate: result["gate"], param: result["param"] },
273
+ meta: task ? { task_id: task.id, session_id: task.session_id } : {}
274
+ ))
87
275
  end
88
276
 
89
277
  # Records the call for debugging in the Studio (name + model args + result +
90
278
  # ms), keyed by the SESSION. Masking/truncation is the ToolTraceStore's job;
91
279
  # here we only collect. NEVER breaks the turn (trace is observability).
280
+ def capture_shared_result(call_id, result)
281
+ return unless @state.respond_to?(:shared_conversations) && @state.shared_conversations
282
+
283
+ @state.shared_conversations.record_tool_result(task: @state.task, call_id: call_id, result: result)
284
+ end
285
+
92
286
  def trace(call_id, args, result, started)
287
+ capture_shared_result(call_id, result)
288
+ timing = Thread.current[TIMING_KEY] || {}
289
+ Thread.current[TIMING_KEY] = nil
290
+ ms = started ? ((monotonic - started) * 1000).round : nil
291
+ TurnTiming.current&.tool(real_name, ToolTraceStore.ok?(result), ms)
93
292
  return unless @trace_recorder && @state.task&.session_id
94
293
 
95
294
  @trace_recorder.record(
96
295
  session_id: @state.task.session_id,
97
- entry: { "turn" => @state.turn, "tool" => real_name, "call_id" => call_id.to_s,
296
+ entry: { "task_id" => @state.task.id.to_s, "turn" => @state.turn, "tool" => real_name, "call_id" => call_id.to_s,
98
297
  "args" => args, "result" => result,
99
- "ms" => started ? ((monotonic - started) * 1000).round : nil,
100
- "at" => Time.now.utc.iso8601 }
298
+ "gate" => result.is_a?(Blocked) || result.is_a?(Held) ? result["gate"] : nil,
299
+ "ms" => ms,
300
+ "at" => Time.now.utc.iso8601 }.merge(timing)
101
301
  )
102
302
  rescue StandardError
103
303
  nil
@@ -114,8 +314,18 @@ module Insika
114
314
  # Does the current tool require approval? (names come from the Resolution
115
315
  # via state).
116
316
  def approval_required?
117
- @state.respond_to?(:requires_approval) &&
118
- Array(@state.requires_approval).include?(real_name)
317
+ (@state.respond_to?(:requires_approval) && Array(@state.requires_approval).include?(real_name)) ||
318
+ (__getobj__.respond_to?(:requires_approval?) && __getobj__.requires_approval?)
319
+ end
320
+
321
+ def native_approvals?
322
+ @state.respond_to?(:native_approvals) && @state.native_approvals == true
323
+ end
324
+
325
+ def native_approval_decision(args, call_id)
326
+ @state.approval_coordinator.approval_decision(
327
+ task: @state.task, turn: @state.turn, tool: real_name, args: args, call_id: call_id
328
+ )
119
329
  end
120
330
 
121
331
  # The call's correlation: the provider id (RubyLLM chat, via
@@ -135,11 +345,36 @@ module Insika
135
345
  end
136
346
 
137
347
  # Written BEFORE the tool result returns to the model.
138
- def record_side_effect!(call_id)
348
+ def record_side_effect!(call_id, **metadata)
139
349
  return if call_id.to_s.empty?
140
350
 
141
351
  @checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
142
- tool_call_id: call_id)
352
+ tool_call_id: call_id, **metadata)
353
+ end
354
+
355
+ # ---- fencing ----------------------------------------------
356
+
357
+ # After the evidence reshape (the lean envelope is already the shape the
358
+ # model reads): every String leaf sanitized, keys and non-strings untouched,
359
+ # each leaf capped at the platform's `fencing.max_chars`. Off = bytes
360
+ # identical to today. An error hash is engine-authored — never touched.
361
+ def fence(result)
362
+ return result unless Insika::Fence.enabled?(@state.profile)
363
+ return result if result.is_a?(Hash) && (result[:error] || result["error"])
364
+
365
+ max = (@state.respond_to?(:fence_max_chars) && @state.fence_max_chars) || Insika::Fence::DEFAULT_MAX_CHARS
366
+ return Insika::Fence.sanitize_value(result, max_chars: max) unless lean_evidence?(result)
367
+
368
+ # A lean evidence result: the LINES are third-party text, the IDS are keys.
369
+ # The ledger recorded the ids byte-exact and a presentation or a write joins
370
+ # on them, so NFKC must not touch them (a fullwidth digit in a SKU would
371
+ # stop matching the moment the model repeated it).
372
+ items = result["items"].map { |i| i.merge("line" => Insika::Fence.sanitize_text(i["line"].to_s, max_chars: max)) }
373
+ result.merge("items" => items)
374
+ end
375
+
376
+ def lean_evidence?(result)
377
+ evidence_spec && result.is_a?(Hash) && result["items"].is_a?(Array)
143
378
  end
144
379
 
145
380
  # ---- evidence ---------------------------------------------
@@ -204,6 +439,8 @@ module Insika
204
439
 
205
440
  @state.evidence_attachments ||= []
206
441
  @state.evidence_attachments.concat(attachments)
442
+ ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
443
+ ledger.record_cards(attachments) if ledger.respond_to?(:record_cards)
207
444
  end
208
445
  end
209
446
  end
@@ -73,6 +73,7 @@ module Insika
73
73
  "response" => binding[:response],
74
74
  "secret_headers" => binding[:secret_headers],
75
75
  "side_effect" => t["side_effect"],
76
+ "timeout" => t["timeout"],
76
77
  "group" => t["group"] || defaults["group"], # per-group allowlist:
77
78
  "tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
78
79
  # Per-tool only: "this result ends the turn" is a property of THIS backend's
@@ -35,6 +35,9 @@ module Insika
35
35
  # -> [String] names, lexicographic order.
36
36
  def names = @cs.keys(SCOPE)
37
37
 
38
+ # Changes whenever any config is written, in any process (ConfigStore#version_token).
39
+ def version_token = @cs.version_token
40
+
38
41
  # -> [Hash] MASKED definitions (for the UI).
39
42
  def all
40
43
  names.filter_map { |n| get(n) }
@@ -55,10 +58,13 @@ module Insika
55
58
  raise Insika::ValidationError, "tool '#{name}' already exists" if create_only && existing
56
59
 
57
60
  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
- )
61
+ # A presentation tool makes no request — nothing to reconcile.
62
+ if final["request"]
63
+ final["request"]["headers"] = reconcile_secret_headers(
64
+ final["request"]["headers"], definition.secret_headers,
65
+ existing&.dig("definition", "request", "headers")
66
+ )
67
+ end
62
68
 
63
69
  rec = build_record(final, existing)
64
70
  @cs.put(SCOPE, name, rec)
@@ -110,6 +116,8 @@ module Insika
110
116
  secret = definition["secret_headers"] || []
111
117
  return definition if secret.empty?
112
118
 
119
+ return definition unless definition["request"]
120
+
113
121
  headers = (definition.dig("request", "headers") || {}).each_with_object({}) do |(k, v), acc|
114
122
  acc[k] = secret.include?(k) ? SecretMasking.mask(v) : v
115
123
  end
@@ -37,6 +37,11 @@ module Insika
37
37
  nil
38
38
  end
39
39
 
40
+ # Conventional tool error = Hash with key "error"/:error (everything else is ok).
41
+ def self.ok?(result)
42
+ !(result.is_a?(Hash) && (result.key?("error") || result.key?(:error)))
43
+ end
44
+
40
45
  # -> [Hash] session entries in chronological order. [] if none.
41
46
  def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
42
47
 
@@ -49,19 +54,21 @@ module Insika
49
54
  e = stringify_keys(entry)
50
55
  {
51
56
  "turn" => e["turn"], "tool" => e["tool"].to_s, "call_id" => e["call_id"].to_s,
52
- "ok" => ok?(e["result"]),
57
+ "ok" => self.class.ok?(e["result"]),
53
58
  "args" => clip(mask(e["args"])), "result" => clip(mask(e["result"])),
54
59
  "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)))
60
+ }.tap do |trace|
61
+ trace["task_id"] = e["task_id"].to_s if e["task_id"]
62
+ trace["gate"] = e["gate"].to_s if e["gate"]
63
+ # Timing breakdown a data-tool reports (see DataDefinedTool#record_timing).
64
+ %w[pre_ms http_ms server_ms].each { |k| trace[k] = e[k] if e[k].is_a?(Integer) }
65
+ end
61
66
  end
62
67
 
63
68
  # Masks values of sensitive keys (recursive); the rest pass through intact.
64
- def mask(obj)
69
+ def mask(obj) = self.class.mask(obj)
70
+
71
+ def self.mask(obj)
65
72
  case obj
66
73
  when Hash
67
74
  obj.each_with_object({}) do |(k, v), acc|