insika 0.9.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 (257) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -2
  3. data/README.md +5 -3
  4. data/bin/insika +3 -0
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +90 -7
  7. data/docs/ARCHITECTURE.md +12 -7
  8. data/docs/ARTIFACTS.md +33 -3
  9. data/docs/BENCHMARK.md +50 -7
  10. data/docs/CONTEXT.md +63 -0
  11. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  12. data/docs/DEPLOY.md +57 -11
  13. data/docs/EMBEDDING.md +10 -0
  14. data/docs/EVALS.md +33 -4
  15. data/docs/FACTS.md +8 -6
  16. data/docs/HARVEST.md +8 -3
  17. data/docs/KNOWLEDGE.md +7 -0
  18. data/docs/MEDIA.md +6 -2
  19. data/docs/OBSERVABILITY.md +143 -22
  20. data/docs/PERFORMANCE.md +110 -0
  21. data/docs/PLUGINS.md +4 -4
  22. data/docs/POLICY.md +22 -13
  23. data/docs/RELEASING.md +5 -0
  24. data/docs/ROUTER.md +7 -0
  25. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  26. data/docs/SECURITY.md +7 -0
  27. data/docs/SHARED-CONVERSATIONS.md +40 -0
  28. data/docs/SKILLS.md +5 -3
  29. data/docs/STUDIO.md +122 -0
  30. data/docs/TOOLS.md +111 -9
  31. data/docs/operate.md +3 -0
  32. data/lib/insika/agent_defaults.rb +140 -0
  33. data/lib/insika/agent_file_store.rb +12 -0
  34. data/lib/insika/agent_profile.rb +78 -8
  35. data/lib/insika/artifact_signing.rb +11 -0
  36. data/lib/insika/chat_builder.rb +79 -25
  37. data/lib/insika/checkpoint.rb +6 -1
  38. data/lib/insika/checkpoint_store.rb +59 -7
  39. data/lib/insika/commands/agent_payload.rb +3 -3
  40. data/lib/insika/commands/delete_kit.rb +26 -0
  41. data/lib/insika/commands/gate_harvest.rb +3 -0
  42. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  43. data/lib/insika/commands/run_harvest.rb +17 -1
  44. data/lib/insika/commands/seed_session.rb +11 -1
  45. data/lib/insika/commands/send_message.rb +9 -3
  46. data/lib/insika/commands/update_settings.rb +23 -1
  47. data/lib/insika/commands/write_kit.rb +27 -0
  48. data/lib/insika/compaction.rb +33 -7
  49. data/lib/insika/config_store.rb +87 -7
  50. data/lib/insika/context/priority.rb +3 -0
  51. data/lib/insika/context/provider.rb +2 -2
  52. data/lib/insika/context/providers/knowledge.rb +25 -2
  53. data/lib/insika/context/providers/memory.rb +53 -4
  54. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  55. data/lib/insika/context/providers/prompt.rb +17 -4
  56. data/lib/insika/context/providers/session.rb +39 -3
  57. data/lib/insika/context/reranker.rb +75 -0
  58. data/lib/insika/distill.rb +22 -13
  59. data/lib/insika/doctor.rb +34 -4
  60. data/lib/insika/dsl/runtime.rb +36 -4
  61. data/lib/insika/dsl/server_boot.rb +4 -12
  62. data/lib/insika/dsl.rb +13 -5
  63. data/lib/insika/edge_limiter.rb +23 -15
  64. data/lib/insika/egress_guard.rb +11 -3
  65. data/lib/insika/env_schema.rb +33 -73
  66. data/lib/insika/evals/assertions.rb +171 -16
  67. data/lib/insika/evals/golden.rb +141 -4
  68. data/lib/insika/evals/judge.rb +19 -10
  69. data/lib/insika/evals/moderator_compare.rb +47 -0
  70. data/lib/insika/evals/report.rb +61 -9
  71. data/lib/insika/evals/runner.rb +44 -4
  72. data/lib/insika/evals/simulator.rb +10 -1
  73. data/lib/insika/evals/transport.rb +200 -0
  74. data/lib/insika/event_stream.rb +63 -7
  75. data/lib/insika/evidence.rb +29 -8
  76. data/lib/insika/executor.rb +668 -141
  77. data/lib/insika/harvest.rb +26 -14
  78. data/lib/insika/harvest_engine.rb +1 -0
  79. data/lib/insika/harvest_store.rb +12 -0
  80. data/lib/insika/http_client.rb +87 -4
  81. data/lib/insika/kits.rb +54 -0
  82. data/lib/insika/knowledge.rb +34 -20
  83. data/lib/insika/llm_configurator.rb +46 -4
  84. data/lib/insika/llm_provider_store.rb +1 -3
  85. data/lib/insika/llm_trace_store.rb +54 -0
  86. data/lib/insika/mcp_client.rb +27 -63
  87. data/lib/insika/mcp_live_tool.rb +55 -9
  88. data/lib/insika/mcp_store.rb +32 -1
  89. data/lib/insika/mcp_tool_registry.rb +69 -26
  90. data/lib/insika/media.rb +38 -25
  91. data/lib/insika/memory_store.rb +4 -3
  92. data/lib/insika/memory_trim.rb +60 -0
  93. data/lib/insika/middleware.rb +12 -3
  94. data/lib/insika/model_metrics_store.rb +285 -0
  95. data/lib/insika/model_registry.rb +110 -0
  96. data/lib/insika/model_resolver.rb +1 -1
  97. data/lib/insika/model_selection.rb +14 -21
  98. data/lib/insika/model_visible.rb +17 -6
  99. data/lib/insika/onboarding.rb +1 -0
  100. data/lib/insika/outcome_store.rb +1 -1
  101. data/lib/insika/overlay_tool_registry.rb +25 -2
  102. data/lib/insika/pack_importer.rb +54 -9
  103. data/lib/insika/packaging.rb +14 -5
  104. data/lib/insika/pending_action_store.rb +45 -18
  105. data/lib/insika/plugin/loader.rb +4 -2
  106. data/lib/insika/profile_source.rb +4 -0
  107. data/lib/insika/provider_error_classifier.rb +18 -3
  108. data/lib/insika/queue_policy.rb +7 -2
  109. data/lib/insika/refinement/proposer.rb +42 -17
  110. data/lib/insika/reliability.rb +12 -3
  111. data/lib/insika/router/app.rb +14 -5
  112. data/lib/insika/router/session_key.rb +35 -5
  113. data/lib/insika/safety/config.rb +20 -3
  114. data/lib/insika/safety/corpus.rb +10 -3
  115. data/lib/insika/safety/factory.rb +39 -1
  116. data/lib/insika/safety/input_guardrail.rb +21 -1
  117. data/lib/insika/safety/judge_moderator.rb +70 -0
  118. data/lib/insika/safety/output_validator.rb +29 -5
  119. data/lib/insika/safety/prompt_echo.rb +89 -0
  120. data/lib/insika/schema_guard.rb +53 -76
  121. data/lib/insika/server/app.rb +17 -8
  122. data/lib/insika/server/responses.rb +19 -1
  123. data/lib/insika/session_actor.rb +58 -28
  124. data/lib/insika/session_store.rb +159 -10
  125. data/lib/insika/settings_store.rb +47 -1
  126. data/lib/insika/shared_conversations.rb +305 -0
  127. data/lib/insika/skill_catalog.rb +28 -2
  128. data/lib/insika/skill_store.rb +6 -0
  129. data/lib/insika/soak/runner.rb +1 -1
  130. data/lib/insika/steer_injector.rb +7 -48
  131. data/lib/insika/store.rb +29 -1
  132. data/lib/insika/stores/json_value.rb +37 -0
  133. data/lib/insika/stores/memory.rb +22 -35
  134. data/lib/insika/stores/postgres.rb +295 -0
  135. data/lib/insika/stores/sqlite.rb +257 -43
  136. data/lib/insika/stores/turn_counter.rb +25 -0
  137. data/lib/insika/studio/app.rb +1453 -161
  138. data/lib/insika/studio/assets/dist/application.css +1 -1
  139. data/lib/insika/studio/assets/dist/application.js +25 -25
  140. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  141. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  142. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  143. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  144. data/lib/insika/studio/forms.rb +171 -9
  145. data/lib/insika/studio/nav_icons.rb +3 -0
  146. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  147. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  148. data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
  149. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  150. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  151. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  152. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  153. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  154. data/lib/insika/studio/views/_agents_master.erb +8 -12
  155. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  156. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  157. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  158. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  159. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  160. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  161. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  162. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  163. data/lib/insika/studio/views/_chats_master.erb +66 -0
  164. data/lib/insika/studio/views/_customers_master.erb +37 -0
  165. data/lib/insika/studio/views/_empty.erb +15 -0
  166. data/lib/insika/studio/views/_facts_master.erb +42 -0
  167. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  168. data/lib/insika/studio/views/_identity.erb +16 -0
  169. data/lib/insika/studio/views/_kpi.erb +17 -0
  170. data/lib/insika/studio/views/_message.erb +40 -2
  171. data/lib/insika/studio/views/_model_chart.erb +82 -0
  172. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  173. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
  174. data/lib/insika/studio/views/_tool_users.erb +17 -0
  175. data/lib/insika/studio/views/_why.erb +53 -0
  176. data/lib/insika/studio/views/agent_detail.erb +17 -10
  177. data/lib/insika/studio/views/agents.erb +28 -16
  178. data/lib/insika/studio/views/approvals.erb +16 -11
  179. data/lib/insika/studio/views/artifact.erb +20 -7
  180. data/lib/insika/studio/views/artifacts.erb +19 -14
  181. data/lib/insika/studio/views/chats.erb +22 -29
  182. data/lib/insika/studio/views/customer.erb +88 -43
  183. data/lib/insika/studio/views/customers.erb +16 -26
  184. data/lib/insika/studio/views/evals.erb +49 -31
  185. data/lib/insika/studio/views/fact_detail.erb +114 -0
  186. data/lib/insika/studio/views/facts.erb +15 -116
  187. data/lib/insika/studio/views/followups.erb +63 -52
  188. data/lib/insika/studio/views/funnel.erb +104 -43
  189. data/lib/insika/studio/views/harvest.erb +85 -59
  190. data/lib/insika/studio/views/home.erb +138 -23
  191. data/lib/insika/studio/views/inbox.erb +36 -0
  192. data/lib/insika/studio/views/kits.erb +176 -0
  193. data/lib/insika/studio/views/knowledge.erb +92 -80
  194. data/lib/insika/studio/views/layout.erb +42 -11
  195. data/lib/insika/studio/views/live.erb +45 -0
  196. data/lib/insika/studio/views/mcp.erb +43 -13
  197. data/lib/insika/studio/views/models.erb +187 -0
  198. data/lib/insika/studio/views/parity.erb +38 -32
  199. data/lib/insika/studio/views/playground.erb +13 -9
  200. data/lib/insika/studio/views/refinement.erb +15 -224
  201. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  202. data/lib/insika/studio/views/session.erb +152 -117
  203. data/lib/insika/studio/views/settings.erb +86 -22
  204. data/lib/insika/studio/views/skills.erb +53 -19
  205. data/lib/insika/studio/views/system_files.erb +7 -7
  206. data/lib/insika/studio/views/task.erb +45 -15
  207. data/lib/insika/studio/views/tasks.erb +35 -18
  208. data/lib/insika/studio/views/tools.erb +62 -73
  209. data/lib/insika/task_actor.rb +4 -1
  210. data/lib/insika/task_store.rb +67 -1
  211. data/lib/insika/telemetry/pricing.rb +16 -14
  212. data/lib/insika/telemetry/recorder.rb +103 -9
  213. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  214. data/lib/insika/telemetry.rb +12 -2
  215. data/lib/insika/templates/daily-digest/README.md +13 -0
  216. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  217. data/lib/insika/testing/store_contract.rb +65 -0
  218. data/lib/insika/text_tool_call.rb +66 -0
  219. data/lib/insika/tick.rb +7 -1
  220. data/lib/insika/token_store.rb +5 -0
  221. data/lib/insika/tool_batch.rb +1 -1
  222. data/lib/insika/tool_catalog.rb +12 -2
  223. data/lib/insika/tool_definition.rb +10 -4
  224. data/lib/insika/tool_envelope.rb +165 -28
  225. data/lib/insika/tool_manifest.rb +1 -0
  226. data/lib/insika/tool_store.rb +3 -0
  227. data/lib/insika/tool_trace_store.rb +15 -8
  228. data/lib/insika/tool_usage_report.rb +2 -2
  229. data/lib/insika/tools/a2a_remote.rb +1 -1
  230. data/lib/insika/tools/agent_enum.rb +1 -1
  231. data/lib/insika/tools/confirm_pending.rb +112 -0
  232. data/lib/insika/tools/data_defined_tool.rb +23 -15
  233. data/lib/insika/tools/generate_image.rb +2 -2
  234. data/lib/insika/tools/load_knowledge.rb +7 -3
  235. data/lib/insika/tools/load_skill.rb +3 -3
  236. data/lib/insika/tools/present.rb +1 -8
  237. data/lib/insika/tools/remember.rb +7 -2
  238. data/lib/insika/tools/run_persona_eval.rb +3 -5
  239. data/lib/insika/tools/save_artifact.rb +16 -6
  240. data/lib/insika/tools/schedule_followup.rb +3 -3
  241. data/lib/insika/tools/stuck_signal.rb +3 -3
  242. data/lib/insika/tools/subagent.rb +5 -5
  243. data/lib/insika/tools/subagents.rb +3 -3
  244. data/lib/insika/tools/tool_search.rb +7 -5
  245. data/lib/insika/tools/tts.rb +2 -2
  246. data/lib/insika/tools/update_briefing.rb +4 -4
  247. data/lib/insika/turn_output.rb +2 -2
  248. data/lib/insika/turn_state.rb +18 -0
  249. data/lib/insika/turn_timing.rb +51 -14
  250. data/lib/insika/version.rb +1 -1
  251. data/lib/insika/wiring/graph.rb +81 -16
  252. data/lib/insika/workflow.rb +25 -64
  253. data/lib/insika.rb +19 -3
  254. metadata +69 -13
  255. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  256. data/lib/insika/mcp_http_client.rb +0 -67
  257. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -9,13 +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
+
15
17
  PROVENANCE_INSTRUCTION = "This value was not returned by any tool in this conversation. " \
16
18
  "Find it with a tool that returns it — a search or a lookup by id — " \
17
19
  "then call this tool again with an id from that result."
18
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
+
19
35
  # The tool timeout's OWN class: distinct from Async::TimeoutError so that
20
36
  # the rescue below NEVER swallows the TURN timeout (which uses the default of
21
37
  # with_timeout). Without this, a turn overflowing while the fiber is inside a
@@ -24,6 +40,11 @@ module Insika
24
40
  ToolTimeout = Class.new(StandardError)
25
41
  private_constant :ToolTimeout
26
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
+
27
48
  # A gate's refusal. A plain Hash subclass: it reaches the model exactly as the
28
49
  # `{status:, gate:, ...}` it always was, and the engine's own readers (the
29
50
  # :tool_result outcome, the trace) recognize a refusal by CLASS — a data tool
@@ -31,6 +52,11 @@ module Insika
31
52
  # not one, whatever keys it happens to use.
32
53
  class Blocked < Hash; end
33
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
+
34
60
  def initialize(tool, state:, checkpoint_store:, tool_registry:, timeout:,
35
61
  skip_side_effects: [], trace_recorder: nil, event_stream: nil)
36
62
  super(tool)
@@ -39,19 +65,51 @@ module Insika
39
65
  @tool_registry = tool_registry
40
66
  @timeout = timeout
41
67
  @event_stream = event_stream
42
- @skip_side_effects = Array(skip_side_effects) # ids already completed in the interrupted turn
68
+ @skip_side_effects = Array(skip_side_effects).dup # ids already completed in the interrupted turn
43
69
  @trace_recorder = trace_recorder # duck-type: #record(session_id:, entry:). nil = no trace.
44
70
  end
45
71
 
46
72
  # Entry point that RubyLLM invokes (Tool#call in the pinned version).
47
73
  # A timeout overflow returns to the MODEL as a serialized error — it does
48
74
  # not bring down the turn.
49
- 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)
50
102
  # A non-idempotent tool call ALREADY COMPLETED in the interrupted
51
103
  # turn -> respond with a marker, NEVER re-execute. The marker returns to
52
104
  # the model, keeping the tool-use protocol intact.
53
105
  call_id = correlation_id
54
- return { "skipped" => "already_executed" } if call_id && @skip_side_effects.include?(call_id)
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
55
113
 
56
114
  started = monotonic
57
115
  if (blocked = provenance_block(args))
@@ -60,28 +118,44 @@ module Insika
60
118
  return blocked
61
119
  end
62
120
 
63
- # Approval gate: a tool marked `approval` suspends the turn in
64
- # :waiting until the operator resolves it. Delegates to the coordinator (the
65
- # Executor), which creates/queries the PendingAction and blocks via the
66
- # mailbox. Rejection returns to the MODEL as an error (the turn continues),
67
- # it does not bring down the turn. CancelledError/TimeoutError from the wait
68
- # propagate (they are not ToolTimeout).
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).
69
132
  if approval_required?
70
- decision = @state.approval_coordinator.request_approval(
71
- task: @state.task, turn: @state.turn, tool: real_name, args: args, actor: @state.actor
72
- )
73
- 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
74
141
  end
75
142
 
76
143
  started = monotonic
77
- 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) } }
78
146
  # the ONE seam every tool result passes on its way to the model.
79
147
  # For a declared-evidence tool: reshape to the lean envelope, record the ids
80
148
  # on the ledger, hoard the attachments. No evidence = the result passes
81
149
  # through untouched (one nil-check — parity).
150
+ capture_shared_result(call_id, result)
82
151
  result = process_evidence(result)
83
152
  result = fence(result)
84
- record_side_effect!(call_id) if side_effect?
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
85
159
  trace(call_id, args, result, started)
86
160
  result
87
161
  rescue ToolTimeout
@@ -90,10 +164,17 @@ module Insika
90
164
  err
91
165
  end
92
166
 
93
- private
94
-
95
167
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
96
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
+
97
178
  # with parallel tool calls on, the turn's shared semaphore
98
179
  # (TurnState#tool_gate, sized by `limits[:tool_concurrency]`) caps how many run
99
180
  # at once. Wraps the REAL call ONLY — the approval wait and the skip check are
@@ -144,11 +225,46 @@ module Insika
144
225
  # (DataDefinedTool exposes its definition's schema; a code tool exposes none
145
226
  # -> every declared parameter is treated as required).
146
227
  def optional_params(tool)
147
- return [] unless tool.respond_to?(:params_schema) && (schema = tool.params_schema).is_a?(Hash)
228
+ return [] unless tool.respond_to?(:parameters_schema) && (schema = tool.parameters_schema).is_a?(Hash)
229
+
230
+ schema = Coercion.deep_stringify(schema)
148
231
 
149
232
  (schema["properties"] || {}).keys.map(&:to_s) - Array(schema["required"]).map(&:to_s)
150
233
  end
151
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
+
152
268
  def emit_blocked(result)
153
269
  task = @state.task # nil on a one-shot turn, like `trace` already assumes
154
270
  @event_stream&.emit(Insika::Event.new(
@@ -161,16 +277,27 @@ module Insika
161
277
  # Records the call for debugging in the Studio (name + model args + result +
162
278
  # ms), keyed by the SESSION. Masking/truncation is the ToolTraceStore's job;
163
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
+
164
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)
165
292
  return unless @trace_recorder && @state.task&.session_id
166
293
 
167
294
  @trace_recorder.record(
168
295
  session_id: @state.task.session_id,
169
- 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,
170
297
  "args" => args, "result" => result,
171
- "gate" => result.is_a?(Blocked) ? result["gate"] : nil,
172
- "ms" => started ? ((monotonic - started) * 1000).round : nil,
173
- "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)
174
301
  )
175
302
  rescue StandardError
176
303
  nil
@@ -187,8 +314,18 @@ module Insika
187
314
  # Does the current tool require approval? (names come from the Resolution
188
315
  # via state).
189
316
  def approval_required?
190
- @state.respond_to?(:requires_approval) &&
191
- 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
+ )
192
329
  end
193
330
 
194
331
  # The call's correlation: the provider id (RubyLLM chat, via
@@ -208,11 +345,11 @@ module Insika
208
345
  end
209
346
 
210
347
  # Written BEFORE the tool result returns to the model.
211
- def record_side_effect!(call_id)
348
+ def record_side_effect!(call_id, **metadata)
212
349
  return if call_id.to_s.empty?
213
350
 
214
351
  @checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
215
- tool_call_id: call_id)
352
+ tool_call_id: call_id, **metadata)
216
353
  end
217
354
 
218
355
  # ---- fencing ----------------------------------------------
@@ -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) }
@@ -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
- }.tap { |trace| trace["gate"] = e["gate"].to_s if e["gate"] }
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|
@@ -87,8 +87,8 @@ module Insika
87
87
  # workflows without a chat) contributes nothing.
88
88
  def sessions_by_agent
89
89
  acc = Hash.new { |h, k| h[k] = [] }
90
- @task_store.each_id do |task_id|
91
- task = @task_store.find(task_id) or next
90
+ tasks = @task_store.respond_to?(:all) ? @task_store.all : @task_store.each_id.filter_map { |id| @task_store.find(id) }
91
+ tasks.each do |task|
92
92
  agent = task.command.is_a?(Hash) ? task.command.dig("payload", "agent") : nil
93
93
  next if agent.to_s.empty? || task.session_id.to_s.empty?
94
94
 
@@ -9,7 +9,7 @@ module Insika
9
9
  # enter lib/insika.rb; it is loaded in the registration block (wiring) on the 1st
10
10
  # instance. One instance per remote agent (its own name/description).
11
11
  class A2ARemote < RubyLLM::Tool
12
- param :message, desc: "The message/task for the remote agent"
12
+ parameter :message, description: "The message/task for the remote agent"
13
13
 
14
14
  def initialize(client:, url:, tool_name:, description:, event_stream: nil)
15
15
  @client = client
@@ -11,7 +11,7 @@ module Insika
11
11
  # the provider sees.
12
12
  #
13
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
14
+ # class-level `parameters(...)` form) or string keys (anything that round-tripped
15
15
  # through JSON), and a shape we do not recognize is returned UNTOUCHED — a
16
16
  # schema we failed to annotate still works, one we corrupted would break every
17
17
  # delegation.
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # What the two confirmation tools share: finding the customer's hold and
8
+ # refusing anyone else's — another conversation's, the operator's, or one
9
+ # already decided. Errors go back to the MODEL as bodies, never as
10
+ # exceptions: a tool exception never ends the turn.
11
+ module Confirmation
12
+ CUSTOMER = PendingActionStore::CUSTOMER
13
+
14
+ module_function
15
+
16
+ # -> [PendingAction, nil] | [nil, String]
17
+ def open_hold(state, pending_id)
18
+ store = state.pending_action_store
19
+ pending = store&.find(pending_id.to_s)
20
+ return [nil, "no held action with pending_id #{pending_id}"] if pending.nil?
21
+ return [nil, "#{pending_id} is not a customer confirmation"] unless pending.kind == CUSTOMER
22
+ return [nil, "#{pending_id} belongs to another conversation"] unless pending.session_id.to_s == state.task.session_id.to_s
23
+ # The hold and the answer to it are two customer messages, which is two
24
+ # TASKS. A hold THIS task created has not been put to the customer yet —
25
+ # the reply carrying it has not even been sent. Deciding it here would be
26
+ # the model confirming itself, and the write would land with nobody's word
27
+ # behind it.
28
+ return [nil, "#{pending_id} was held on this same turn; the customer has not answered it yet"] if pending.task_id.to_s == state.task.id.to_s
29
+ return [nil, "#{pending_id} was already #{pending.status}"] unless pending.status == :pending
30
+
31
+ [pending, nil]
32
+ end
33
+
34
+ # The turn's enveloped instance of the held tool — the same wrapper the
35
+ # hold came from, so the confirmed run pays every check but the hold.
36
+ def held_tool(state, name)
37
+ Array(state.allowed_tools).find do |tool|
38
+ tool.respond_to?(:call_confirmed) &&
39
+ (tool.name.to_s == name || (tool.respond_to?(:impl_name) && tool.impl_name.to_s == name))
40
+ end
41
+ end
42
+
43
+ def emit(event_stream, state, type, pending)
44
+ event_stream&.emit(Insika::Event.new(
45
+ type: type,
46
+ data: { pending_id: pending.id, tool: pending.tool },
47
+ meta: { task_id: state.task.id, session_id: state.task.session_id }
48
+ ))
49
+ end
50
+ end
51
+
52
+ # Runs an action the engine held for the customer's confirmation. The
53
+ # decision is written FIRST (durable — a crash between the two re-executes
54
+ # the turn and finds it approved), the original tool runs SECOND through its
55
+ # own envelope with the arguments recorded at the hold: there is no path to
56
+ # confirm different arguments than the ones the customer was shown.
57
+ # System builtin (like remember): `require "ruby_llm"` stays in THIS file,
58
+ # loaded lazily by the Executor in create_chat.
59
+ class ConfirmPending < RubyLLM::Tool
60
+ description "Runs an action the customer has just confirmed. Call it only when the customer's " \
61
+ "message agrees to exactly what was proposed; a change, a question or a new request " \
62
+ "is cancel_pending instead."
63
+ parameter :pending_id, description: "The pending_id the held action returned"
64
+
65
+ def name = "confirm_pending"
66
+
67
+ def initialize(event_stream:, state:)
68
+ @event_stream = event_stream
69
+ @state = state
70
+ super()
71
+ end
72
+
73
+ def execute(pending_id:)
74
+ pending, error = Confirmation.open_hold(@state, pending_id)
75
+ return { error: error } if error
76
+
77
+ tool = Confirmation.held_tool(@state, pending.tool)
78
+ return { error: "#{pending.tool} is not available on this turn" } unless tool
79
+
80
+ @state.pending_action_store.resolve(pending.id, decision: :approved, operator: Confirmation::CUSTOMER)
81
+ Confirmation.emit(@event_stream, @state, :confirmation_confirmed, pending)
82
+ tool.call_confirmed(pending.args || {})
83
+ end
84
+ end
85
+
86
+ # Drops a held action: the customer declined, changed something, or asked
87
+ # for anything else. The hold would expire on its own at the end of this
88
+ # turn; cancelling says so now, and lets the model answer accordingly.
89
+ class CancelPending < RubyLLM::Tool
90
+ description "Drops an action that was waiting for the customer's confirmation, because they " \
91
+ "declined, changed something, or asked for something else."
92
+ parameter :pending_id, description: "The pending_id the held action returned"
93
+
94
+ def name = "cancel_pending"
95
+
96
+ def initialize(event_stream:, state:)
97
+ @event_stream = event_stream
98
+ @state = state
99
+ super()
100
+ end
101
+
102
+ def execute(pending_id:)
103
+ pending, error = Confirmation.open_hold(@state, pending_id)
104
+ return { error: error } if error
105
+
106
+ @state.pending_action_store.resolve(pending.id, decision: :rejected, operator: Confirmation::CUSTOMER)
107
+ Confirmation.emit(@event_stream, @state, :confirmation_cancelled, pending)
108
+ { "status" => "cancelled", "tool" => pending.tool }
109
+ end
110
+ end
111
+ end
112
+ end
@@ -12,7 +12,7 @@ module Insika
12
12
  # it is NOT required in lib/insika.rb; the overlay loads it lazily at registration
13
13
  #
14
14
  # Contract preserved by duck-typing: it overrides name/description/parameters/
15
- # execute; RubyLLM's params_schema derives from #parameters automatically.
15
+ # execute; RubyLLM's parameters_schema derives from #parameters automatically.
16
16
  # execute NEVER raises — an error (missing param, blocked egress, HTTP, parse)
17
17
  # becomes `{ error: }` to the model, like the other tools.
18
18
  class DataDefinedTool < RubyLLM::Tool
@@ -49,20 +49,11 @@ module Insika
49
49
  def evidence = @definition.evidence
50
50
  def requires_evidence = @definition.requires_evidence
51
51
 
52
- # FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
52
+ # FULL (nested) JSON Schema straight into RubyLLM's parameters_schema — it is what
53
53
  # the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
54
- # params_schema; parameters is just a fallback). Provider-agnostic and
54
+ # parameters_schema; parameters is just a fallback). Provider-agnostic and
55
55
  # the only form that expresses nesting (object/array/enum).,.
56
- def params_schema = @definition.parameters
57
-
58
- # FLAT top-level view for discovery (tool_search calls #parameters on the resolved
59
- # tool). The real nested schema goes through #params_schema above.
60
- def parameters
61
- @parameters ||= @definition.top_level_params.each_with_object({}) do |p, acc|
62
- sym = p[:name].to_sym
63
- acc[sym] = RubyLLM::Parameter.new(sym, type: p[:type], desc: p[:description], required: p[:required])
64
- end
65
- end
56
+ def parameters_schema = @definition.parameters
66
57
 
67
58
  # The args are checked against the tool's own JSON Schema BEFORE the request is
68
59
  # built: a call the schema does not allow becomes `{ error: }` the model can act
@@ -72,16 +63,19 @@ module Insika
72
63
  return { error: bad }
73
64
  end
74
65
 
66
+ t0 = monotonic
75
67
  req = build_request(kwargs)
76
68
  reason = @egress.violation(req[:url], **@egress_options)
77
69
  return { error: "destination blocked: #{reason}" } if reason
78
70
 
71
+ t1 = monotonic
79
72
  result = @http.request(**req)
73
+ record_timing(t0, t1, result)
80
74
  emit(result[:status])
81
75
  payload = extract(result)
82
76
  # The RESPONSE says the turn is over (`halt_when`): the backend already
83
77
  # answered the customer, so letting the model comment would deliver the
84
- # message twice. RubyLLM's Tool::Halt ends its loop right here — no second
78
+ # message twice. Insika::ToolDefinition::Halt ends its loop right here — no second
85
79
  # provider call, and the decision is the engine's, not a request in a prompt.
86
80
  # Only on a 2xx: an error body that happens to carry the value is a failure,
87
81
  # and a failure must reach the model.
@@ -90,7 +84,7 @@ module Insika
90
84
  # the model wrote no lead-in. Wrapped only when there is one, so every tool
91
85
  # that declares no `say` keeps producing exactly the payload it always did.
92
86
  say = @definition.halt_say(result[:body])
93
- return RubyLLM::Tool::Halt.new(say ? Insika::ToolDefinition.wrap_halt(payload, say) : payload)
87
+ return Insika::ToolDefinition::Halt.new(say ? Insika::ToolDefinition.wrap_halt(payload, say) : payload)
94
88
  end
95
89
 
96
90
  payload
@@ -220,6 +214,20 @@ module Insika
220
214
 
221
215
  # No task correlation (registry tool does not receive TurnState) -> meta {}.
222
216
  # Emits only name + status: NEVER body/headers (0 secret leakage, R2).
217
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
218
+
219
+ # Where the call's time went, for the ToolEnvelope's trace (same fiber): the
220
+ # request build + egress check, the HTTP round trip as this process saw it, and
221
+ # the server's own runtime when it reports one. A wide gap between http_ms and
222
+ # server_ms is network or this process being slow to resume the call.
223
+ def record_timing(t0, t1, result)
224
+ Thread.current[Insika::ToolEnvelope::TIMING_KEY] = {
225
+ "pre_ms" => ((t1 - t0) * 1000).round,
226
+ "http_ms" => ((monotonic - t1) * 1000).round,
227
+ "server_ms" => result[:server_ms]
228
+ }.compact
229
+ end
230
+
223
231
  def emit(status)
224
232
  @event_stream&.emit(Insika::Event.new(
225
233
  type: :data_tool_call, data: { tool: @definition.name, status: status }, meta: {}
@@ -27,9 +27,9 @@ module Insika
27
27
  "prompt alone — UNLESS this turn carries an inbound photo, in which case " \
28
28
  "that photo is edited by default (pass source_image_urls explicitly to " \
29
29
  "generate from scratch instead)."
30
- # explicit JSON-schema form (the `param` DSL only reaches strings/scalars,
30
+ # explicit JSON-schema form (the `parameter` DSL only reaches strings/scalars,
31
31
  # and source_image_urls needs a typed array — the bare-array gotcha, #128).
32
- params(
32
+ parameters(
33
33
  type: "object",
34
34
  properties: {
35
35
  prompt: { type: "string", description: "What to draw, or what edit to make, in detail" },
@@ -16,7 +16,7 @@ module Insika
16
16
  # loads it lazily inside create_chat, same as load_skill.
17
17
  class LoadKnowledge < RubyLLM::Tool
18
18
  description "Loads the complete content of a learned concept by name"
19
- param :name, desc: "Exact concept name, as listed in <knowledge>"
19
+ parameter :name, description: "Exact concept name, as listed in <knowledge>"
20
20
 
21
21
  # RubyLLM::Tool#name derives from self.class.name — for a nested class it
22
22
  # produces "insika--tools--load_knowledge", not "load_knowledge" (which
@@ -47,7 +47,11 @@ module Insika
47
47
  private
48
48
 
49
49
  def load(name)
50
- content = @store.get(@agent_id, name.to_s, tenant: @tenant)
50
+ content = if @state&.respond_to?(:shared_conversations) && @state.shared_conversations
51
+ @state.shared_conversations.memory_get(task:@state.task,id:name.to_s,kind:'knowledge')&.fetch('value')
52
+ else
53
+ @store.get(@agent_id, name.to_s, tenant: @tenant)
54
+ end
51
55
  return { error: "concept '#{name}' not found" } unless content
52
56
 
53
57
  content
@@ -61,7 +65,7 @@ module Insika
61
65
 
62
66
  @trace_recorder.record(
63
67
  session_id: @state.task.session_id,
64
- entry: { "turn" => @state.turn, "tool" => "load_knowledge", "call_id" => "",
68
+ entry: { "task_id" => @state.task.id.to_s, "turn" => @state.turn, "tool" => "load_knowledge", "call_id" => "",
65
69
  "args" => { "name" => name.to_s }, "result" => result,
66
70
  "ms" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
67
71
  "at" => Time.now.utc.iso8601 }