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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +1 -1
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- 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", {
|
|
73
|
-
expect(store.get("s", "k")).to eq({ "
|
|
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", "
|
|
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", "
|
|
82
|
-
store.set("s", "k", "
|
|
83
|
-
expect(store.get("s", "k")).to eq("
|
|
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
|
data/lib/insika/tool_envelope.rb
CHANGED
|
@@ -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
|
data/lib/insika/tool_manifest.rb
CHANGED
|
@@ -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
|