insika 0.2.0 → 0.3.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -69,18 +69,18 @@ RSpec.shared_examples "an Insika store" do
69
69
  end
70
70
 
71
71
  it " converts Symbols (keys and values) to Strings" do #
72
- store.set("s", "k", { chave: :valor })
73
- expect(store.get("s", "k")).to eq({ "chave" => "valor" })
72
+ store.set("s", "k", { key: :value })
73
+ expect(store.get("s", "k")).to eq({ "key" => "value" })
74
74
  end
75
75
 
76
76
  it " returns nil for an absent key, never an exception" do #
77
- expect(store.get("s", "nao-existe")).to be_nil
77
+ expect(store.get("s", "missing-key")).to be_nil
78
78
  end
79
79
 
80
80
  it " overwrites silently (last-write-wins)" do #
81
- store.set("s", "k", "primeiro")
82
- store.set("s", "k", "segundo")
83
- expect(store.get("s", "k")).to eq("segundo")
81
+ store.set("s", "k", "first")
82
+ store.set("s", "k", "second")
83
+ expect(store.get("s", "k")).to eq("second")
84
84
  end
85
85
 
86
86
  it " set returns the same object passed in (not the round-trip)" do #
@@ -128,6 +128,27 @@ RSpec.shared_examples "an Insika store" do
128
128
  end
129
129
  end
130
130
 
131
+ describe "#scopes" do
132
+ it " returns scope names sorted lexicographically" do #
133
+ store.set("zeta", "k", 1)
134
+ store.set("alpha", "k", 1)
135
+ store.set("alpha:child", "k", 1)
136
+ expect(store.scopes).to eq(["alpha", "alpha:child", "zeta"])
137
+ end
138
+
139
+ it " filters by prefix with start_with? (not include?)" do #
140
+ store.set("s", "k", 1)
141
+ store.set("s:child", "k", 1)
142
+ store.set("s2", "k", 1) # trap: contains "s" but does not start with "s:"
143
+ expect(store.scopes("s:")).to eq(["s:child"])
144
+ end
145
+
146
+ it " returns [] when nothing matches (or the store is empty)" do #
147
+ expect(store.scopes("nope")).to eq([])
148
+ expect(store.scopes).to eq([])
149
+ end
150
+ end
151
+
131
152
  describe "scope isolation" do
132
153
  it " keeps scopes independent in get/list/delete" do #
133
154
  store.set("s1", "k", 1)
data/lib/insika/tick.rb CHANGED
@@ -31,7 +31,7 @@ module Insika
31
31
 
32
32
  def initialize(store:, recovery:, channel_delivery:, logger: nil,
33
33
  interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
34
- sleeper: nil)
34
+ sleeper: nil, retention: nil, funnel: nil, followup: nil)
35
35
  @store = store
36
36
  @recovery = recovery
37
37
  @channel_delivery = channel_delivery
@@ -39,8 +39,20 @@ module Insika
39
39
  @interval = interval.to_i
40
40
  @stale_after = stale_after.to_i
41
41
  @sleeper = sleeper || method(:default_sleep)
42
+ @retention = retention # WS8: the daily age-based sweep; nil = none
43
+ @funnel = funnel # the tick-driven outcome fold; nil = none
44
+ @followup = followup # the tick-driven follow-up firer; nil = none
42
45
  end
43
46
 
47
+ # the fold is wired after the Tick is built (the graph passes
48
+ # it to `executor.tick.funnel =` — the outcome/funnel stores come from the
49
+ # spine). Setter + kwarg: same shape as `retention`.
50
+ attr_accessor :funnel
51
+
52
+ # the follow-up firer, wired after the Tick is built (same
53
+ # shape as `funnel` — the stores come from the spine).
54
+ attr_accessor :followup
55
+
44
56
  def enabled? = @interval.positive?
45
57
 
46
58
  # One pass, pure (no reactor needed): the serving loop calls it on a timer,
@@ -49,6 +61,18 @@ module Insika
49
61
  def run_once
50
62
  drained = @channel_delivery ? @channel_delivery.sweep : { dispatched: [] }
51
63
  summary = { dispatched: drained[:dispatched], resumed: [], failed: [] }
64
+ # WS8 retention: cheap when not claimed (its own daily window) — the
65
+ # O(n) scans never ride the 60 s loop.
66
+ retention_summary = @retention&.run
67
+ summary[:retention] = retention_summary if retention_summary
68
+ # the outcome fold — one pass per claim window, cheap
69
+ # when another worker holds it. Sits next to retention, on the same tick.
70
+ funnel_summary = @funnel&.run
71
+ summary[:funnel] = funnel_summary if funnel_summary
72
+ # the follow-up firer — the tick's third duty, gated by its
73
+ # OWN claim window so the O(n) scans never ride the 60 s loop.
74
+ followup_summary = @followup&.run
75
+ summary[:followup] = followup_summary if followup_summary
52
76
  return summary unless claim_window
53
77
 
54
78
  result = @recovery.run(stale_after: @stale_after)
@@ -34,7 +34,8 @@ module Insika
34
34
  # (masks/reconciles); the definition itself is agnostic to masking.
35
35
  ToolDefinition = Data.define(
36
36
  :name, :description, :parameters, :request, :response,
37
- :secret_headers, :side_effect, :timeout, :group, :tags, :halt_when
37
+ :secret_headers, :side_effect, :timeout, :group, :tags, :halt_when,
38
+ :evidence # Insika::Evidence::Spec | nil
38
39
  )
39
40
 
40
41
  class ToolDefinition
@@ -46,7 +47,7 @@ module Insika
46
47
  ARRAY_SUGAR = PARAM_TYPES.map { |t| "array:#{t}" }.freeze
47
48
  HTTP_METHODS = %w[GET HEAD POST PUT PATCH DELETE].freeze
48
49
  IDEMPOTENT = %w[GET HEAD].freeze # side_effect default = false
49
- EXTRACTS = %w[body_raw status json_path].freeze
50
+ EXTRACTS = %w[body_raw status json_path evidence_envelope].freeze
50
51
  NAME_RE = /\A[a-z][a-z0-9_]*\z/ # identifier for the model
51
52
  # A `.` in the placeholder enables the turn-context namespace `{{ctx.*}}`
52
53
  # separate from the model's `{{param}}`. Params follow NAME_RE (no
@@ -56,7 +57,7 @@ module Insika
56
57
  # resolved by DataDefinedTool. Closed allowlist (a typo becomes a validation
57
58
  # error, not a silently empty header).
58
59
  CTX_PREFIX = "ctx."
59
- CTX_FIELDS = %w[chat_id store_id agent_id tenant].freeze
60
+ CTX_FIELDS = %w[chat_id store_id agent_id tenant image_url].freeze
60
61
 
61
62
  # ---- safe subset of JSON Schema (R1) --------------------------------------
62
63
  # Types supported by EVERY provider (OpenAI/Anthropic/Gemini/DeepSeek/Bedrock).
@@ -74,7 +75,7 @@ module Insika
74
75
  # `parameters` accepts JSON Schema (Hash) OR the legacy flat array.
75
76
  def self.build(name:, description:, request:, parameters: nil, response: nil,
76
77
  secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
77
- halt_when: nil)
78
+ halt_when: nil, evidence: nil)
78
79
  name = name.to_s
79
80
  raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)
80
81
 
@@ -84,6 +85,10 @@ module Insika
84
85
  schema = normalize_params(parameters)
85
86
  req = normalize_request(request, top_level_names(schema))
86
87
  resp = normalize_response(response)
88
+ if resp[:extract] == "evidence_envelope" && evidence.nil?
89
+ raise Insika::ValidationError,
90
+ "extract 'evidence_envelope' requires an 'evidence' declaration"
91
+ end
87
92
 
88
93
  method = req[:method]
89
94
  effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)
@@ -93,7 +98,8 @@ module Insika
93
98
  secret_headers: Array(secret_headers).map(&:to_s), side_effect: effect,
94
99
  timeout: timeout.nil? ? nil : Integer(timeout),
95
100
  group: normalize_group(group), tags: normalize_tags(tags),
96
- halt_when: normalize_halt_when(halt_when)
101
+ halt_when: normalize_halt_when(halt_when),
102
+ evidence: Insika::Evidence::Spec.parse(evidence)
97
103
  )
98
104
  end
99
105
 
@@ -104,7 +110,7 @@ module Insika
104
110
  name: h[:name], description: h[:description], parameters: h[:parameters],
105
111
  request: h[:request] || {}, response: h[:response],
106
112
  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]
113
+ group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence]
108
114
  )
109
115
  end
110
116
 
@@ -405,7 +411,7 @@ module Insika
405
411
  # String-keyed Hash for persistence (ConfigStore stringifies again, but we
406
412
  # normalize here so the record is stable across backends).
407
413
  def to_h
408
- {
414
+ h = {
409
415
  "name" => name, "description" => description,
410
416
  "parameters" => parameters,
411
417
  "request" => request.transform_keys(&:to_s),
@@ -415,6 +421,10 @@ module Insika
415
421
  "group" => group, "tags" => tags,
416
422
  "halt_when" => halt_when&.transform_keys(&:to_s)
417
423
  }
424
+ # present only when declared — a tool without evidence is byte-identical
425
+ # to today (no declaration, no envelope processing).
426
+ h["evidence"] = evidence.to_h if evidence
427
+ h
418
428
  end
419
429
 
420
430
  # -> true when this response ENDS the turn (no further model call). `body` is the
@@ -56,6 +56,11 @@ module Insika
56
56
 
57
57
  started = monotonic
58
58
  result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { __getobj__.call(args) } }
59
+ # the ONE seam every tool result passes on its way to the model.
60
+ # For a declared-evidence tool: reshape to the lean envelope, record the ids
61
+ # on the ledger, hoard the attachments. No evidence = the result passes
62
+ # through untouched (one nil-check — parity).
63
+ result = process_evidence(result)
59
64
  record_side_effect!(call_id) if side_effect?
60
65
  trace(call_id, args, result, started)
61
66
  result
@@ -136,5 +141,69 @@ module Insika
136
141
  @checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
137
142
  tool_call_id: call_id)
138
143
  end
144
+
145
+ # ---- evidence ---------------------------------------------
146
+
147
+ # The evidence spec for the wrapped tool (D4). Resolution order:
148
+ # 1. the wrapped tool responds to `evidence` -> its spec (the data-tool
149
+ # path — DataDefinedTool exposes its definition's evidence);
150
+ # 2. otherwise the tool_registry entry's metadata carries an `evidence`
151
+ # spec (the code-tool path — a registry tool opts in at registration).
152
+ # No spec = pass the result through untouched (parity, byte-identical).
153
+ def evidence_spec
154
+ tool = __getobj__
155
+ if tool.respond_to?(:evidence)
156
+ raw = tool.evidence
157
+ return raw && Insika::Evidence::Spec.parse(raw)
158
+ end
159
+
160
+ entry = @tool_registry.respond_to?(:entries) ? registry_entry(real_name) : nil
161
+ metadata = entry&.respond_to?(:metadata) ? entry.metadata : nil
162
+ raw = metadata && (metadata[:evidence] || metadata["evidence"])
163
+ raw && Insika::Evidence::Spec.parse(raw)
164
+ end
165
+
166
+ def registry_entry(name)
167
+ @tool_registry.entries.find { |e| e.name == name.to_s }
168
+ end
169
+
170
+ # -> result (possibly reshaped). NEVER raises out: a broken evidence result
171
+ # becomes the envelope error the model can act on, exactly like a malformed
172
+ # CALL is today. A tool ERROR result is never reshaped (an error must reach
173
+ # the model verbatim — the DataDefinedTool rule).
174
+ def process_evidence(result)
175
+ spec = evidence_spec
176
+ return result unless spec
177
+ return result if result.is_a?(Hash) && (result[:error] || result["error"])
178
+
179
+ raw = Insika::Evidence::Processor.raw(spec, result)
180
+ bad = Insika::SchemaGuard.violation_output(spec, raw)
181
+ return { error: bad } if bad
182
+
183
+ lean, attachments = Insika::Evidence::Processor.build(spec, raw)
184
+ record_evidence!(spec, lean)
185
+ hoard_attachments!(attachments)
186
+ lean
187
+ rescue StandardError => e
188
+ { error: "evidence processing failed: #{e.message}" }
189
+ end
190
+
191
+ # Ledger write + attachment hoarding, both via the state (duck-typed — the
192
+ # envelope's existing specs construct state stubs without these readers).
193
+ def record_evidence!(_spec, lean)
194
+ ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
195
+ return unless ledger
196
+
197
+ ids = Array(lean["items"]).map { |i| i["id"] }
198
+ ledger.record(ids) unless ids.empty?
199
+ end
200
+
201
+ def hoard_attachments!(attachments)
202
+ return if attachments.empty?
203
+ return unless @state.respond_to?(:evidence_attachments)
204
+
205
+ @state.evidence_attachments ||= []
206
+ @state.evidence_attachments.concat(attachments)
207
+ end
139
208
  end
140
209
  end
@@ -77,7 +77,11 @@ module Insika
77
77
  "tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
78
78
  # Per-tool only: "this result ends the turn" is a property of THIS backend's
79
79
  # response, never something a manifest default should hand to its siblings.
80
- "halt_when" => t["halt_when"]
80
+ "halt_when" => t["halt_when"],
81
+ # the evidence declaration (lean envelope + ledger feed are the
82
+ # SAME data contract — D1). Normalized here; a malformed declaration is a
83
+ # ValidationError at ingestion, isolable per tool (R4).
84
+ "evidence" => t["evidence"] && Insika::Evidence::Spec.parse(t["evidence"]).to_h
81
85
  }.compact
82
86
  end
83
87
 
@@ -43,6 +43,11 @@ module Insika
43
43
  def name = @definition.name
44
44
  def description = @definition.description
45
45
 
46
+ # the tool's own evidence declaration (a Spec | nil). The
47
+ # envelope's duck-typed resolution checks this FIRST — a data-tool declares
48
+ # its evidence on its definition, never in the registry metadata.
49
+ def evidence = @definition.evidence
50
+
46
51
  # FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
47
52
  # the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
48
53
  # params_schema; parameters is just a fallback). Provider-agnostic and
@@ -148,6 +153,11 @@ module Insika
148
153
  when "status" then { status: result[:status] }
149
154
  when "body_raw" then http_ok?(result) ? result[:body] : http_error(result)
150
155
  when "json_path" then extract_json(result)
156
+ # the raw response body under an envelope-only key, so the
157
+ # ToolEnvelope can parse items/attachments. A non-2xx is an ERROR like
158
+ # any other extract — an error must reach the model verbatim.
159
+ when "evidence_envelope"
160
+ http_ok?(result) ? { "__insika_body" => result[:body].to_s } : http_error(result)
151
161
  end
152
162
  end
153
163
 
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # The agent's IMAGE output (WS9, saída). The engine transports media, never
8
+ # meaning: what the image IS for (a virtual try-on, a product mockup) is the
9
+ # skill's business — the contract here just produces the bytes and carries
10
+ # them in the turn's `output_parts`.
11
+ #
12
+ # Wired ONLY when both gates pass (ChatBuilder): the agent opted in
13
+ # (`outputs.image`) AND the channel declared it can receive the media
14
+ # (`channel.capabilities` includes "image_output") — nothing leaks by
15
+ # default. The image is an envelope part, never part of the answer text;
16
+ # the provider's tokens are merged into the turn's usage like any ask.
17
+ class GenerateImage < RubyLLM::Tool
18
+ description "Generate an image and attach it to the reply as an output part. " \
19
+ "Use when the customer asked for a picture or an image would help."
20
+ param :prompt, desc: "What to draw, in detail"
21
+ param :size, desc: "Optional canvas size, e.g. 1024x1024 (default from the agent config)",
22
+ required: false
23
+
24
+ def name = "generate_image"
25
+
26
+ # runner: a duck exposing #generate_media_output(:image, prompt, config)
27
+ # -> [part, usage] and #account_media_usage(part, usage) (the Executor).
28
+ def initialize(runner:, config:, state:, **)
29
+ @runner = runner
30
+ @config = config
31
+ @state = state
32
+ super()
33
+ end
34
+
35
+ def execute(prompt:, size: nil)
36
+ cfg = @config.merge("size" => size.to_s).reject { |_, v| v.to_s.empty? }
37
+ part, usage = @runner.generate_media_output(:image, prompt.to_s, cfg)
38
+ @state.output_parts << part
39
+ @runner.account_media_usage(@state, part, usage)
40
+ "image generated and attached to the reply (#{part["mime_type"]})"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "time"
5
+
6
+ module Insika
7
+ module Tools
8
+ # `schedule` — the agent books a follow-up with a customer at
9
+ # a future time. The tool ONLY validates shape + the dedup rule (D6/D7):
10
+ # everything that can block (contact state, frequency, quiet hours, a
11
+ # malformed policy) is decided at FIRE time by the policy in force then —
12
+ # the schedule is a promise made in-conversation. The consent record IS
13
+ # the tool call itself: the customer agreeing in-conversation writes
14
+ # :granted.
15
+ #
16
+ # System builtin (like remember): `require "ruby_llm"` stays in THIS file,
17
+ # loaded lazily by the Executor in create_chat. Never enveloped.
18
+ class ScheduleFollowup < RubyLLM::Tool
19
+ description "Schedule a follow-up with this customer at a future time. Use " \
20
+ "when the customer agrees to be contacted again (a product, a " \
21
+ "cart, a pending payment). The cancellation policy is permanent: " \
22
+ "a customer who opted out can never be rescheduled."
23
+ param :at, desc: "ISO 8601 (absolute) or relative '+6h' / '+2d'"
24
+ param :reason, desc: "Short machine-readable reason, e.g. 'pix pending, " \
25
+ "customer said she would pay tonight'"
26
+
27
+ def name = "schedule"
28
+
29
+ def initialize(contact_store:, followup_store:, state:, event_stream: nil, **)
30
+ @contact_store = contact_store
31
+ @followup_store = followup_store
32
+ @state = state
33
+ @event_stream = event_stream
34
+ super()
35
+ end
36
+
37
+ def execute(at:, reason:)
38
+ followup = @state.profile.followup
39
+ # D9: a malformed policy is a tool error naming the rule (the
40
+ # ValidationError rescue below).
41
+ Insika::FollowupPolicy.parse!(followup)
42
+
43
+ resolved = resolve_at(at)
44
+ return { error: resolved } if resolved.is_a?(String)
45
+
46
+ reason = reason.to_s.strip
47
+ return { error: "reason is required (1..200 chars)" } if reason.empty? || reason.length > 200
48
+
49
+ at_time = resolved
50
+ return { error: "the follow-up must be at least 5 minutes in the future" } if at_time < Time.now.utc + 300
51
+
52
+ tenant = @state.respond_to?(:tenant) ? @state.tenant : nil
53
+ # D7: the tool call IS the consent — recorded WITHOUT un-silencing a
54
+ # silent customer (only a customer message reopens, D2). A revoked
55
+ # customer raises (the error names the opt-out).
56
+ @contact_store.consent(tenant: tenant, customer: customer)
57
+ record = @followup_store.create(
58
+ tenant: tenant, agent: @state.profile.id, customer: customer,
59
+ session_id: @state.task&.session_id, at: at_time, reason: reason,
60
+ arm: arm(followup), transport: transport
61
+ )
62
+ emit(record.id, record.at)
63
+ { scheduled: record.id, at: record.at, reason: record.reason }
64
+ rescue Insika::ValidationError => e
65
+ { error: e.message }
66
+ end
67
+
68
+ private
69
+
70
+ # The customer is the SAME string the message contract carries — from
71
+ # the running task's command, never from the model's arguments.
72
+ def customer
73
+ command = @state.task&.command
74
+ return nil unless command.is_a?(Hash)
75
+
76
+ payload = command["payload"] || command[:payload] || {}
77
+ payload["customer"] || payload[:customer]
78
+ end
79
+
80
+ def arm(followup)
81
+ return Insika::FollowupPolicy::DEFAULT_ARM unless followup.is_a?(Hash)
82
+
83
+ arm = followup["arm"].to_s
84
+ arm.empty? ? Insika::FollowupPolicy::DEFAULT_ARM : arm
85
+ end
86
+
87
+ # The transport is provenance captured at schedule time (D5): the fired
88
+ # turn's reply travels out of band through the SAME channel.
89
+ def transport
90
+ command = @state.task&.command
91
+ return nil unless command.is_a?(Hash)
92
+
93
+ meta = command["meta"] || command[:meta] || {}
94
+ meta["transport"] || meta[:transport]
95
+ end
96
+
97
+ # ISO-8601 UTC or `+(\d+)([mhd])+` relative forms; anything else is a
98
+ # tool error. -> Time | String (the error).
99
+ def resolve_at(raw)
100
+ text = raw.to_s.strip
101
+ if (m = text.match(/\A\+(\d+)([mhd])\z/))
102
+ seconds = m[1].to_i * { "m" => 60, "h" => 3600, "d" => 86_400 }.fetch(m[2])
103
+ return Time.now.utc + seconds
104
+ end
105
+ begin
106
+ Time.iso8601(text).utc
107
+ rescue ArgumentError
108
+ return "`at` must be ISO 8601 (absolute) or relative '+6h' / '+2d'"
109
+ end
110
+ end
111
+
112
+ # :followup_scheduled carries ids + at, NEVER the reason text — a reason
113
+ # is contact data that rides the record and the Studio, not the events.
114
+ def emit(id, at)
115
+ @event_stream&.emit(Insika::Event.new(
116
+ type: :followup_scheduled,
117
+ data: { id: id, at: at },
118
+ meta: { task_id: @state.task&.id, session_id: @state.task&.session_id }
119
+ ))
120
+ end
121
+ end
122
+
123
+ # `cancel_followup` — the sibling of `schedule`. Refuses a
124
+ # record that is already fired (":fired — it is in the air; it fires
125
+ # once"), refuses a record of another tenant (WS1), and is an idempotent
126
+ # no-op for an already-cancelled one.
127
+ class CancelFollowup < RubyLLM::Tool
128
+ description "Cancel a previously scheduled follow-up by its id. A follow-up " \
129
+ "that already fired cannot be cancelled."
130
+ param :id, desc: "The id returned by the schedule tool"
131
+
132
+ def name = "cancel_followup"
133
+
134
+ def initialize(followup_store:, state:, **)
135
+ @followup_store = followup_store
136
+ @state = state
137
+ super()
138
+ end
139
+
140
+ def execute(id:)
141
+ record = @followup_store.find(id.to_s)
142
+ return { error: "no follow-up with id #{id}" } if record.nil?
143
+ if record.status == "fired"
144
+ return { error: "follow-up #{id} is already fired — it is in the air; it fires once" }
145
+ end
146
+ if record.status == "blocked"
147
+ return { error: "follow-up #{id} is already blocked (#{record.blocked_reason})" }
148
+ end
149
+
150
+ tenant = (@state.respond_to?(:tenant) ? @state.tenant : nil).to_s
151
+ unless record.tenant == (tenant.empty? ? "platform" : tenant)
152
+ return { error: "follow-up #{id} belongs to another tenant" }
153
+ end
154
+
155
+ return { cancelled: record.id } if record.status == "cancelled" # idempotent
156
+
157
+ @followup_store.cancel(id: record.id)
158
+ { cancelled: record.id }
159
+ rescue Insika::ValidationError => e
160
+ { error: e.message }
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # The agent's SPEECH output (WS9, saída). The engine transports media, never
8
+ # meaning: the words belong to the customer's channel (a voice note on
9
+ # WhatsApp), and the contract here just produces the audio bytes and carries
10
+ # them in the turn's `output_parts`.
11
+ #
12
+ # Wired ONLY when both gates pass (ChatBuilder): the agent opted in
13
+ # (`outputs.tts`) AND the channel declared it can receive the media
14
+ # (`channel.capabilities` includes "audio_output") — nothing leaks by
15
+ # default. The clip is an envelope part, never part of the answer text;
16
+ # the turn counts the call in its usage (`usage.media`) — the provider's
17
+ # speech API reports no token counts, so the part carries the model for
18
+ # consumer-side pricing.
19
+ class Tts < RubyLLM::Tool
20
+ description "Synthesize speech for the reply — a voice clip travels as an " \
21
+ "output part in the envelope, the channel supports it. Use when the " \
22
+ "customer should HEAR the answer rather than read it."
23
+ param :text, desc: "The words to speak"
24
+ param :voice, desc: "Optional voice override (default from the agent config)",
25
+ required: false
26
+
27
+ def name = "tts"
28
+
29
+ # runner: a duck exposing #generate_media_output(:tts, text, config)
30
+ # -> [part, usage] and #account_media_usage(part, usage) (the Executor).
31
+ def initialize(runner:, config:, state:, **)
32
+ @runner = runner
33
+ @config = config
34
+ @state = state
35
+ super()
36
+ end
37
+
38
+ def execute(text:, voice: nil)
39
+ cfg = @config.merge("voice" => voice.to_s).reject { |_, v| v.to_s.empty? }
40
+ part, usage = @runner.generate_media_output(:tts, text.to_s, cfg)
41
+ @state.output_parts << part
42
+ @runner.account_media_usage(@state, part, usage)
43
+ "speech synthesized and attached to the reply (#{part["mime_type"]})"
44
+ end
45
+ end
46
+ end
47
+ end