insika 0.1.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 +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -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 +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- 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/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- 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/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- 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/frontmatter.rb +1 -1
- 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 +3 -3
- 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/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- 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 +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- 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/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- 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 +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- 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/steer_injector.rb +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- 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/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- 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/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
5
|
require "openssl"
|
|
6
|
+
require "time"
|
|
6
7
|
require "uri"
|
|
7
8
|
|
|
8
9
|
module Insika
|
|
9
10
|
module Channels
|
|
10
11
|
# The channel for an adopter who ALREADY owns a messaging integration
|
|
11
|
-
#
|
|
12
|
+
# A WhatsApp BSP, a Zendesk, a legacy Rails app: they want the
|
|
12
13
|
# engine for the TURN, not for the platform. Two routes and an envelope —
|
|
13
14
|
#
|
|
14
15
|
# consumer --POST /channels/relay/events--> engine acked now, never the reply
|
|
@@ -16,7 +17,7 @@ module Insika
|
|
|
16
17
|
#
|
|
17
18
|
# — and everything platform-shaped stays theirs: the 24-hour window, template
|
|
18
19
|
# approval, media, read receipts, and how markdown becomes WhatsApp formatting
|
|
19
|
-
#
|
|
20
|
+
# That is the promise, not the limitation: an integration someone has
|
|
20
21
|
# already tuned for years does not have to move for them to adopt the engine.
|
|
21
22
|
# A relay that starts growing template logic has stopped being a relay.
|
|
22
23
|
#
|
|
@@ -33,12 +34,25 @@ module Insika
|
|
|
33
34
|
DEFAULT_ID = "relay"
|
|
34
35
|
DEFAULT_TIMEOUT = 10
|
|
35
36
|
|
|
37
|
+
# how the outbox flushes for THIS channel. `:at_end` (the
|
|
38
|
+
# default) is one POST with the whole answer, byte-identical to today;
|
|
39
|
+
# `:progressive` lets ChannelDelivery split the answer into balloons and
|
|
40
|
+
# POST them in order (the channel still only translates — it does not know
|
|
41
|
+
# what a balloon is).
|
|
42
|
+
POLICIES = %i[at_end progressive].freeze
|
|
43
|
+
|
|
36
44
|
attr_reader :id
|
|
37
45
|
|
|
38
46
|
# The bundled relay as an operator configures it: three env vars, of which the
|
|
39
47
|
# token is the SWITCH — no token, no channel, so there is no way to end up with
|
|
40
48
|
# this route mounted and open. -> Relay | nil.
|
|
41
49
|
#
|
|
50
|
+
# `INSIKA_RELAY_SHADOW` (truthy) is the shadow switch: the turn
|
|
51
|
+
# runs end to end and the reply is recorded, never delivered.
|
|
52
|
+
#
|
|
53
|
+
# `INSIKA_RELAY_DELIVERY` ("progressive" | "at_end") is how the outbox
|
|
54
|
+
# flushes. Unset = :at_end.
|
|
55
|
+
#
|
|
42
56
|
# Shared by every composition root on purpose: the DSL front door has to reach
|
|
43
57
|
# the same feature as `config.ru`, or the docs are true of only one of them.
|
|
44
58
|
def self.from_env(env = ENV, http: nil, allow_http: false, allow_private: false)
|
|
@@ -48,6 +62,8 @@ module Insika
|
|
|
48
62
|
new(inbound_token: token,
|
|
49
63
|
deliver_url: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_URL", env),
|
|
50
64
|
deliver_token: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_TOKEN", env),
|
|
65
|
+
shadow: Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_RELAY_SHADOW", env)),
|
|
66
|
+
delivery: policy!(Insika::EnvSchema.read("INSIKA_RELAY_DELIVERY", env)),
|
|
51
67
|
http: http, allow_http: allow_http, allow_private: allow_private)
|
|
52
68
|
end
|
|
53
69
|
|
|
@@ -59,9 +75,14 @@ module Insika
|
|
|
59
75
|
# reply and the delivery fails loudly instead of silently).
|
|
60
76
|
# deliver_token: Bearer we send THEM. Optional: a consumer on a private
|
|
61
77
|
# network may authenticate us another way.
|
|
78
|
+
# shadow: The turn runs, the reply is recorded and never
|
|
79
|
+
# sent. Fail-closed by construction: everything downstream
|
|
80
|
+
# duck-types `shadow?`, so a channel that does not answer it
|
|
81
|
+
# is a normal channel.
|
|
82
|
+
# delivery: How the outbox flushes (:at_end | :progressive).
|
|
62
83
|
def initialize(inbound_token:, deliver_url:, deliver_token: nil, http: nil,
|
|
63
84
|
id: DEFAULT_ID, allow_http: false, allow_private: false,
|
|
64
|
-
timeout: DEFAULT_TIMEOUT)
|
|
85
|
+
timeout: DEFAULT_TIMEOUT, shadow: false, delivery: :at_end)
|
|
65
86
|
@id = id.to_s
|
|
66
87
|
@inbound_token = inbound_token.to_s
|
|
67
88
|
@deliver_url = deliver_url.to_s
|
|
@@ -70,10 +91,30 @@ module Insika
|
|
|
70
91
|
@allow_http = allow_http
|
|
71
92
|
@allow_private = allow_private
|
|
72
93
|
@timeout = timeout
|
|
94
|
+
@shadow = shadow
|
|
95
|
+
@delivery = self.class.policy!(delivery)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def shadow? = @shadow
|
|
99
|
+
|
|
100
|
+
def delivery = @delivery
|
|
101
|
+
def progressive? = @delivery == :progressive
|
|
102
|
+
|
|
103
|
+
# "progressive" | "at_end" | blank/unset (= :at_end). An unknown value is
|
|
104
|
+
# a config error at BOOT — the consumer would silently miss every
|
|
105
|
+
# progressive turn, so it is refused where the operator is.
|
|
106
|
+
def self.policy!(value)
|
|
107
|
+
return :at_end if Insika::Coercion.blank?(value)
|
|
108
|
+
|
|
109
|
+
name = value.to_s.strip.downcase.to_sym
|
|
110
|
+
return name if POLICIES.include?(name)
|
|
111
|
+
|
|
112
|
+
raise Insika::ConfigError,
|
|
113
|
+
"unknown relay delivery: #{value.inspect} (expected #{POLICIES.join(', ')})"
|
|
73
114
|
end
|
|
74
115
|
|
|
75
116
|
# -> :ok | :unauthorized | :disabled. A SYMBOL and not a Rack triple (the
|
|
76
|
-
#
|
|
117
|
+
# the design sketched one): a status code is the transport's vocabulary, and keeping
|
|
77
118
|
# it out of here is what lets this class be tested without Rack and read
|
|
78
119
|
# without knowing HTTP.
|
|
79
120
|
def authenticate(req)
|
|
@@ -91,22 +132,28 @@ module Insika
|
|
|
91
132
|
# { "agent": "support", "external_id": "5511999998888",
|
|
92
133
|
# "event_id": "wamid.HBg…", "message": "queria saber do pedido",
|
|
93
134
|
# "vars": { … } }
|
|
135
|
+
#
|
|
136
|
+
# In SHADOW mode `event_id` is REQUIRED: it is the correlation key both
|
|
137
|
+
# halves of the pair are built from, and a mirror that cannot supply a
|
|
138
|
+
# stable id cannot be paired.
|
|
94
139
|
def parse(_req, body:)
|
|
95
140
|
body = body.is_a?(Hash) ? body : {}
|
|
96
141
|
agent = string(body["agent"])
|
|
97
142
|
external_id = string(body["external_id"])
|
|
98
143
|
message = string(body["message"])
|
|
144
|
+
event_id = presence(body["event_id"])
|
|
99
145
|
|
|
100
146
|
raise Insika::ValidationError, "agent is required" if agent.empty?
|
|
101
147
|
raise Insika::ValidationError, "external_id is required" if external_id.empty?
|
|
102
148
|
raise Insika::ValidationError, "message is required" if message.strip.empty?
|
|
149
|
+
raise Insika::ValidationError, "event_id is required in shadow mode" if @shadow && event_id.nil?
|
|
103
150
|
|
|
104
151
|
vars = body["vars"].is_a?(Hash) ? body["vars"] : {}
|
|
105
152
|
{ agent: agent, external_id: external_id, message: message,
|
|
106
|
-
event_id: presence(body["
|
|
153
|
+
event_id: event_id, vars: vars, incumbent_reply: presence(body["incumbent_reply"]) }
|
|
107
154
|
end
|
|
108
155
|
|
|
109
|
-
#
|
|
156
|
+
# the engine namespaces the platform's conversation key, so a
|
|
110
157
|
# Slack channel id and a phone number can never collide, an operator can see
|
|
111
158
|
# where a conversation came from, and an id minted for one channel cannot be
|
|
112
159
|
# used to read another's session.
|
|
@@ -127,6 +174,8 @@ module Insika
|
|
|
127
174
|
# consumer that receives the same delivery twice (we retried after a timeout
|
|
128
175
|
# that actually landed) can drop the second one.
|
|
129
176
|
def deliver(payload, to:, delivery_id: nil)
|
|
177
|
+
raise Insika::DeliveryError, "relay '#{@id}' is in shadow mode and must never deliver" if @shadow
|
|
178
|
+
|
|
130
179
|
raise Insika::DeliveryError, "relay deliver_url is not configured" if @deliver_url.empty?
|
|
131
180
|
|
|
132
181
|
if (reason = egress_violation)
|
|
@@ -143,8 +192,33 @@ module Insika
|
|
|
143
192
|
raise Insika::DeliveryError, "#{e.class}: #{e.message}"
|
|
144
193
|
end
|
|
145
194
|
|
|
195
|
+
# The reply, as recorded by the mirror. Follows the
|
|
196
|
+
# same strictness as `parse`; `at` is optional (nil = now).
|
|
197
|
+
def parse_shadow_reply(_req, body:)
|
|
198
|
+
body = body.is_a?(Hash) ? body : {}
|
|
199
|
+
external_id = string(body["external_id"])
|
|
200
|
+
event_id = presence(body["event_id"])
|
|
201
|
+
reply = string(body["reply"])
|
|
202
|
+
|
|
203
|
+
raise Insika::ValidationError, "external_id is required" if external_id.empty?
|
|
204
|
+
raise Insika::ValidationError, "event_id is required" if event_id.nil?
|
|
205
|
+
raise Insika::ValidationError, "reply is required" if reply.strip.empty?
|
|
206
|
+
|
|
207
|
+
at = presence(body["at"])
|
|
208
|
+
raise Insika::ValidationError, "at must be an ISO8601 timestamp" if at && !parseable_time?(at)
|
|
209
|
+
|
|
210
|
+
{ external_id: external_id, event_id: event_id, reply: reply, at: at }
|
|
211
|
+
end
|
|
212
|
+
|
|
146
213
|
private
|
|
147
214
|
|
|
215
|
+
def parseable_time?(value)
|
|
216
|
+
Time.iso8601(value)
|
|
217
|
+
true
|
|
218
|
+
rescue ArgumentError
|
|
219
|
+
false
|
|
220
|
+
end
|
|
221
|
+
|
|
148
222
|
# Resolved on EVERY call, not once at boot: a hostname that answered a public
|
|
149
223
|
# address yesterday can answer 169.254.169.254 today, and this POST carries
|
|
150
224
|
# the customer's conversation.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Insika web widget
|
|
1
|
+
// Insika web widget.
|
|
2
2
|
//
|
|
3
3
|
// One <script> tag, no framework, no build step, no dependency:
|
|
4
4
|
//
|
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// The engine issues the session id; we only ever store the one it gave us
|
|
179
|
-
// (
|
|
179
|
+
// (a client that proposes its own id on a public endpoint is
|
|
180
180
|
// one enumeration away from reading someone else's conversation).
|
|
181
181
|
function session() {
|
|
182
182
|
var saved = remembered;
|
data/lib/insika/channels/web.rb
CHANGED
|
@@ -6,8 +6,8 @@ require "securerandom"
|
|
|
6
6
|
|
|
7
7
|
module Insika
|
|
8
8
|
module Channels
|
|
9
|
-
# The web widget
|
|
10
|
-
# claim behind the whole
|
|
9
|
+
# The web widget — the FIRST Shape A channel, and the adoption
|
|
10
|
+
# claim behind the whole design: an adopter pastes one tag into their site and has
|
|
11
11
|
# a working agent, with no backend of their own and no build step.
|
|
12
12
|
#
|
|
13
13
|
# <script src="https://agents.example.com/channels/web/asset/widget.js"
|
|
@@ -17,7 +17,7 @@ module Insika
|
|
|
17
17
|
# no outbox, no claim and no `deliver` here — the three routes and a static
|
|
18
18
|
# asset are the entire surface:
|
|
19
19
|
#
|
|
20
|
-
# POST /channels/web/sessions mint an opaque session id (the engine's
|
|
20
|
+
# POST /channels/web/sessions mint an opaque session id (the engine's)
|
|
21
21
|
# POST /channels/web/messages the turn, answered as SSE on this connection
|
|
22
22
|
# GET /channels/web/asset/widget.js
|
|
23
23
|
#
|
|
@@ -29,7 +29,7 @@ module Insika
|
|
|
29
29
|
# · an AGENT allowlist — a visitor addresses the agents the operator published
|
|
30
30
|
# to the widget, not every agent in the deployment;
|
|
31
31
|
# · an ORIGIN allowlist — exact match, no wildcards, and no "allow all" value;
|
|
32
|
-
# · a mandatory chat RATE LIMIT
|
|
32
|
+
# · a mandatory chat RATE LIMIT — the channel answers `:disabled`
|
|
33
33
|
# until one is configured, because a public endpoint with an LLM behind it
|
|
34
34
|
# and no ceiling is an unmetered bill waiting to happen.
|
|
35
35
|
#
|
|
@@ -49,8 +49,8 @@ module Insika
|
|
|
49
49
|
# An unversioned URL cannot be cached for a year — the next release would
|
|
50
50
|
# never reach a browser that already has it. Short max-age + an ETag instead:
|
|
51
51
|
# the common case is a 304 with no body, and an upgrade lands within minutes.
|
|
52
|
-
# (A deliberate narrowing of
|
|
53
|
-
# snippet
|
|
52
|
+
# (A deliberate narrowing of's "long-cache versioned URL": the install
|
|
53
|
+
# snippet has no version in it, so there is nothing to bust.)
|
|
54
54
|
ASSET_CACHE_CONTROL = "public, max-age=300"
|
|
55
55
|
|
|
56
56
|
attr_reader :id
|
|
@@ -67,7 +67,7 @@ module Insika
|
|
|
67
67
|
new(origins: origins, agents: agents, id: id, chat_rate_limit: chat_rate_limit)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
# The probe
|
|
70
|
+
# The probe's gate needs, asked exactly the way `EdgeLimiter` asks it at
|
|
71
71
|
# turn time: the per-agent override first, the platform default second. Built
|
|
72
72
|
# here so both composition roots wire the gate identically, and returned as a
|
|
73
73
|
# lambda so the channel itself stays store-free.
|
|
@@ -149,7 +149,7 @@ module Insika
|
|
|
149
149
|
{ agent: agent, session_id: session_id, message: message }
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
-
#
|
|
152
|
+
#'s hard rule for a public channel: the ENGINE issues the id and the
|
|
153
153
|
# client never proposes one. A visitor-supplied session id on an anonymous
|
|
154
154
|
# endpoint is session hijacking by enumeration, so there is no create-on-write
|
|
155
155
|
# path — `POST /messages` with an unknown id is a 404, not a new conversation.
|
|
@@ -159,7 +159,7 @@ module Insika
|
|
|
159
159
|
# protocol: what to type, what to say while a tool runs, and how it ended.
|
|
160
160
|
#
|
|
161
161
|
# `:intermediate` and `:thinking` are deliberately absent. `:content` is the
|
|
162
|
-
# ANSWER
|
|
162
|
+
# ANSWER — the model's narration on the way there is internal, and a
|
|
163
163
|
# widget that rendered it would show the customer the engine thinking out loud.
|
|
164
164
|
def frame_for(event)
|
|
165
165
|
case event.type
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Channels
|
|
7
|
+
# A webhook as a Shape B "channel": the recipient of operator ALERTS
|
|
8
|
+
# (WS6). One instance per configured URL, registered in the ChannelRegistry
|
|
9
|
+
# so ChannelDelivery's outbox+claim+retry pipeline delivers the alert the
|
|
10
|
+
# same way it delivers a chat answer — at-most-once, bounded retry, boot
|
|
11
|
+
# sweep. Deliberately NOT Slack/CRM/anything: it POSTs the event as JSON and
|
|
12
|
+
# the consumer interprets it (the engine transports, it does not integrate).
|
|
13
|
+
#
|
|
14
|
+
# The target URL is operator configuration, so the POST crosses the SAME
|
|
15
|
+
# egress guard the Relay applies: https-only (fails closed), private/
|
|
16
|
+
# loopback/metadata targets and DNS-rebindable hosts blocked. Without it the
|
|
17
|
+
# `alerts.webhook` URL is an SSRF vector — a URL pointed at cloud metadata
|
|
18
|
+
# or an internal API exfiltrates alert events out of the boundary (WS6).
|
|
19
|
+
class Webhook
|
|
20
|
+
def initialize(url, http:, allow_http: false, allow_private: false)
|
|
21
|
+
@http = http
|
|
22
|
+
@allow_http = allow_http
|
|
23
|
+
@allow_private = allow_private
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The ChannelDelivery contract: -> HTTP status (200..299 = delivered).
|
|
27
|
+
# Every failure becomes a DeliveryError so the bounded retry records it.
|
|
28
|
+
def deliver(payload, to:, delivery_id: nil)
|
|
29
|
+
if (reason = egress_violation(to))
|
|
30
|
+
raise Insika::DeliveryError, "webhook egress blocked for #{to}: #{reason}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
result = @http.request(
|
|
34
|
+
method: :post, url: to,
|
|
35
|
+
headers: { "content-type" => "application/json" },
|
|
36
|
+
body: JSON.generate(payload)
|
|
37
|
+
)
|
|
38
|
+
result[:status].to_i
|
|
39
|
+
rescue Insika::DeliveryError
|
|
40
|
+
raise
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
raise Insika::DeliveryError, "webhook: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Resolved on EVERY delivery, not once at registration: a host that
|
|
48
|
+
# answered a public address yesterday can answer 169.254.169.254 today,
|
|
49
|
+
# and this POST carries the operator's alerts.
|
|
50
|
+
def egress_violation(url)
|
|
51
|
+
Insika::EgressGuard.violation(url, allow_http: @allow_http,
|
|
52
|
+
allow_private: @allow_private)
|
|
53
|
+
rescue URI::InvalidURIError
|
|
54
|
+
"invalid URL"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/insika/chat_builder.rb
CHANGED
|
@@ -13,7 +13,9 @@ module Insika
|
|
|
13
13
|
# Executor, injected as the `emit` callable.
|
|
14
14
|
class ChatBuilder
|
|
15
15
|
def initialize(tool_registry:, skill_catalog:, checkpoint_store:, event_stream:,
|
|
16
|
-
hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil
|
|
16
|
+
hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil,
|
|
17
|
+
tool_trace_store: nil, media_runner: nil, session_store: nil,
|
|
18
|
+
contact_store: nil, followup_store: nil)
|
|
17
19
|
@tool_registry = tool_registry
|
|
18
20
|
@skill_catalog = skill_catalog
|
|
19
21
|
@checkpoint_store = checkpoint_store
|
|
@@ -21,10 +23,25 @@ module Insika
|
|
|
21
23
|
@hooks = hooks
|
|
22
24
|
@tool_catalog = tool_catalog
|
|
23
25
|
@memory_store = memory_store
|
|
24
|
-
#
|
|
26
|
+
# only to trace load_skill, which is not enveloped — nil = no trace (parity).
|
|
27
|
+
@tool_trace_store = tool_trace_store
|
|
28
|
+
# the object exposing #run_subagent (the Executor). nil = the
|
|
25
29
|
# spawn_subagent system tool is never wired (parity for a builder used
|
|
26
30
|
# without delegation, e.g. some unit stubs).
|
|
27
31
|
@subagent_runner = subagent_runner
|
|
32
|
+
# the Executor as the generate_image/tts runner (seams + usage
|
|
33
|
+
# accounting). nil = the media tools are never wired (parity for stubs).
|
|
34
|
+
@media_runner = media_runner
|
|
35
|
+
# update_briefing / set_next_step are the briefing-write system
|
|
36
|
+
# tools — wired only with @session_store present AND profile.briefing_fields
|
|
37
|
+
# non-empty (double gate, like remember). nil = never wired (parity for a
|
|
38
|
+
# builder used without session persistence, e.g. some unit stubs).
|
|
39
|
+
@session_store = session_store
|
|
40
|
+
# the schedule/cancel_followup system tools — wired only
|
|
41
|
+
# with BOTH stores present AND a parsed follow-up policy on the profile
|
|
42
|
+
# (double gate, like remember). nil = never wired (parity).
|
|
43
|
+
@contact_store = contact_store
|
|
44
|
+
@followup_store = followup_store
|
|
28
45
|
end
|
|
29
46
|
|
|
30
47
|
# Configures an already-created chat with the context (stage 2) and the
|
|
@@ -69,9 +86,16 @@ module Insika
|
|
|
69
86
|
|
|
70
87
|
# load_skill is a system default (outside the allowlist), otherwise
|
|
71
88
|
# progressive disclosure breaks. allowed_skills comes from the Resolution
|
|
72
|
-
# (policy)
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
# (policy), minus the EAGER ones: their bodies are already in the prompt, so a
|
|
90
|
+
# call could only pay for a duplicate. Nothing lazy left -> the tool is not
|
|
91
|
+
# wired at all. Keeping it for the discretionary skills is deliberate: that
|
|
92
|
+
# call is the only record of which skill the model actually reached for.
|
|
93
|
+
skill_names = lazy_skill_names(state)
|
|
94
|
+
unless skill_names.empty?
|
|
95
|
+
tools << Tools::LoadSkill.new(@skill_catalog, skill_names,
|
|
96
|
+
trace_recorder: @tool_trace_store, state: state,
|
|
97
|
+
agent: state.profile.id)
|
|
98
|
+
end
|
|
75
99
|
|
|
76
100
|
# remember is the memory-write system tool — wired only with
|
|
77
101
|
# @memory_store present AND profile.memory (a double gate). Never enveloped.
|
|
@@ -80,24 +104,75 @@ module Insika
|
|
|
80
104
|
event_stream: @event_stream, state: state)
|
|
81
105
|
end
|
|
82
106
|
|
|
83
|
-
#
|
|
107
|
+
# update_briefing / set_next_step are the briefing-write system tools
|
|
108
|
+
# — wired only with @session_store present AND
|
|
109
|
+
# profile.briefing_fields non-empty (double gate, like remember). Never
|
|
110
|
+
# enveloped: deterministic in-process writes.
|
|
111
|
+
fields = if state.profile.respond_to?(:briefing_fields)
|
|
112
|
+
Array(state.profile.briefing_fields).map(&:to_s)
|
|
113
|
+
else
|
|
114
|
+
[]
|
|
115
|
+
end
|
|
116
|
+
if @session_store && !fields.empty?
|
|
117
|
+
tools << Tools::UpdateBriefing.new(session_store: @session_store, fields: fields,
|
|
118
|
+
event_stream: @event_stream, state: state)
|
|
119
|
+
tools << Tools::UpdateBriefing::SetNextStep.new(session_store: @session_store,
|
|
120
|
+
event_stream: @event_stream, state: state)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# signal_stuck is the "I cannot proceed" system tool (WS5) — wired only
|
|
124
|
+
# when the agent opted in (`profile.stuck_signal`), never enveloped. It is a
|
|
125
|
+
# deterministic signal; the consumer decides what "stuck" means. Defensive
|
|
126
|
+
# read: a minimal profile double without the reader means off (nil = parity).
|
|
127
|
+
if state.profile.respond_to?(:stuck_signal) && state.profile.stuck_signal
|
|
128
|
+
tools << Tools::StuckSignal.new(state: state)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# schedule/cancel_followup are the follow-up system tools —
|
|
132
|
+
# wired only with BOTH stores present AND a parsed follow-up policy on
|
|
133
|
+
# the profile (double gate, like remember). Never enveloped: the store
|
|
134
|
+
# writes are deterministic. The tools stay OUT of the allowlist — they
|
|
135
|
+
# are data-driven per agent, not admittable (prompts/memory/stuck_signal
|
|
136
|
+
# are the precedent).
|
|
137
|
+
if @contact_store && @followup_store && followup_policy(state)
|
|
138
|
+
tools << Tools::ScheduleFollowup.new(contact_store: @contact_store,
|
|
139
|
+
followup_store: @followup_store,
|
|
140
|
+
state: state,
|
|
141
|
+
event_stream: @event_stream)
|
|
142
|
+
tools << Tools::CancelFollowup.new(followup_store: @followup_store,
|
|
143
|
+
state: state,
|
|
144
|
+
event_stream: @event_stream)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# generate_image / tts are the generated-media system tools (WS9, saída)
|
|
148
|
+
# — wired only when BOTH gates pass, never enveloped: the AGENT opted in
|
|
149
|
+
# (`profile.outputs` — the per-kind generator config) AND the CHANNEL
|
|
150
|
+
# declared it can receive the media (state.channel_capabilities, from the
|
|
151
|
+
# request's `channel.capabilities`). The "abstraction admits only what
|
|
152
|
+
# leaks" rule, C4: a channel that never declared image_output cannot get
|
|
153
|
+
# a generated image; a profile without `outputs` never generates. The
|
|
154
|
+
# runner is the Executor (seams + usage accounting); nil = no media
|
|
155
|
+
# output at all (parity for a stub builder).
|
|
156
|
+
output_media_tools(state).each { |tool| tools << tool } if @media_runner
|
|
157
|
+
|
|
158
|
+
# spawn_subagent is the delegation system tool — wired only with a
|
|
84
159
|
# runner present AND profile.subagents non-empty (a double gate, like
|
|
85
160
|
# remember). Never enveloped: in the synchronous mode the child lives in the
|
|
86
161
|
# parent's envelope. The runtime gate on WHICH agent is spawnable is the
|
|
87
162
|
# parent's subagents allowlist, enforced in Executor#run_subagent.
|
|
88
163
|
if @subagent_runner && !Array(state.profile.subagents).empty?
|
|
89
164
|
tools << Tools::Subagent.new(runner: @subagent_runner, state: state)
|
|
90
|
-
#
|
|
165
|
+
# and its parallel sibling: fan-out N children at once.
|
|
91
166
|
tools << Tools::Subagents.new(runner: @subagent_runner, state: state)
|
|
92
167
|
end
|
|
93
168
|
|
|
94
169
|
unless tools.empty?
|
|
95
|
-
#
|
|
170
|
+
# the ONLY place the gem is told to run tool calls in parallel.
|
|
96
171
|
# `:fibers` is not a preference but the only admissible mode — `:threads`
|
|
97
172
|
# breaks ToolEnvelope's `Async::Task.current.with_timeout`, the SQLite
|
|
98
173
|
# store's fiber semaphore, and the turn's own durability (mailbox,
|
|
99
174
|
# approvals, cancellation are all expressed in fiber terms). The number
|
|
100
|
-
# the operator configured is OUR cap (
|
|
175
|
+
# the operator configured is OUR cap (ToolAssembly#install_tool_gate);
|
|
101
176
|
# the gem has none.
|
|
102
177
|
if tool_concurrency_for(state)
|
|
103
178
|
chat.with_tools(*tools, concurrency: :fibers)
|
|
@@ -109,9 +184,47 @@ module Insika
|
|
|
109
184
|
chat
|
|
110
185
|
end
|
|
111
186
|
|
|
187
|
+
# The skills the model still has to ask for: the Resolution's set minus the eager
|
|
188
|
+
# ones. Intersected by NAME against the catalog's own verdict (SkillCatalog#eager_for)
|
|
189
|
+
# so the tool and the level-1 list can never disagree about who is eager. A catalog
|
|
190
|
+
# without the reader (a unit stub) falls back to the whole set — parity.
|
|
191
|
+
def lazy_skill_names(state)
|
|
192
|
+
names = Array(state.allowed_skills).map { |s| s.respond_to?(:name) ? s.name : s.to_s }
|
|
193
|
+
return names unless @skill_catalog.respond_to?(:eager_for)
|
|
194
|
+
|
|
195
|
+
eager = @skill_catalog.eager_for(state.profile).map(&:name)
|
|
196
|
+
names - eager
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# the follow-up tools' gate — a PARSED policy on the profile.
|
|
200
|
+
# A malformed declaration reads as "no policy" (the firer blocks it, the
|
|
201
|
+
# doctor reports it; the tools are simply not offered).
|
|
202
|
+
def followup_policy(state)
|
|
203
|
+
followup = state.profile.respond_to?(:followup) ? state.profile.followup : nil
|
|
204
|
+
followup && Insika::FollowupPolicy.parse(followup)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# WS9 (saída): the media-output tools this turn may carry, per the double
|
|
208
|
+
# gate (profile outputs ∩ channel capabilities). [] = none. Defensive reads
|
|
209
|
+
# throughout — a minimal profile/state stub is "off", which is the safe
|
|
210
|
+
# parity reading.
|
|
211
|
+
def output_media_tools(state)
|
|
212
|
+
outputs = state.profile.respond_to?(:outputs) ? state.profile.outputs : nil
|
|
213
|
+
return [] unless outputs.is_a?(Hash)
|
|
214
|
+
return [] unless state.respond_to?(:channel_capabilities)
|
|
215
|
+
|
|
216
|
+
caps = Array(state.channel_capabilities).map(&:to_s)
|
|
217
|
+
image_cfg = outputs["image"]
|
|
218
|
+
tts_cfg = outputs["tts"]
|
|
219
|
+
[
|
|
220
|
+
(Tools::GenerateImage.new(runner: @media_runner, config: image_cfg, state: state) if image_cfg.is_a?(Hash) && caps.include?("image_output")),
|
|
221
|
+
(Tools::Tts.new(runner: @media_runner, config: tts_cfg, state: state) if tts_cfg.is_a?(Hash) && caps.include?("audio_output"))
|
|
222
|
+
].compact
|
|
223
|
+
end
|
|
224
|
+
|
|
112
225
|
# The turn's effective tool concurrency (nil = serial), plus the ONE thing the
|
|
113
226
|
# gate owes the operator: when the profile asked for parallel tool calls and
|
|
114
|
-
# this turn silently cannot have them (
|
|
227
|
+
# this turn silently cannot have them (an approval-required tool would
|
|
115
228
|
# deadlock two fibers on the single per-task mailbox), say so once. Otherwise
|
|
116
229
|
# the speedup just vanishes with no reason given. The rule itself lives in
|
|
117
230
|
# TurnState; a state predating those readers (a unit stub) means off.
|
|
@@ -137,7 +250,7 @@ module Insika
|
|
|
137
250
|
))
|
|
138
251
|
end
|
|
139
252
|
|
|
140
|
-
#
|
|
253
|
+
# R3: opt-in Anthropic prompt caching. When the agent enables
|
|
141
254
|
# prompt_caching AND the resolved provider is Anthropic, wrap the system in
|
|
142
255
|
# the provider's native Content helper with cache: true — ONE breakpoint at
|
|
143
256
|
# the END of the system block. By Anthropic's prefix order
|
|
@@ -176,7 +289,7 @@ module Insika
|
|
|
176
289
|
# History comes from the context/checkpoint. The {role:, content:} shape
|
|
177
290
|
# tolerates string keys (JSON from the stores). `flatten(1)` dissolves the
|
|
178
291
|
# Session provider's "eviction units" (an assistant+tool_results cycle grouped
|
|
179
|
-
# as one Array,
|
|
292
|
+
# as one Array, R1) back into a flat message stream.
|
|
180
293
|
#
|
|
181
294
|
# tool_calls / tool_call_id are rehydrated ONLY when present, so a message
|
|
182
295
|
# without them keeps the 2-arg shape the specs' FakeChat expects (no unknown
|
|
@@ -216,6 +329,14 @@ module Insika
|
|
|
216
329
|
tool_calls = 0
|
|
217
330
|
max_tool_calls = state.profile.limits[:max_tool_calls] || 50
|
|
218
331
|
|
|
332
|
+
# the loop detector. Needs #after_message + #add_message for the
|
|
333
|
+
# batch-boundary intervention; a chat without them (smoke shim, minimal
|
|
334
|
+
# double) stays bounded by max_tool_calls alone — never half-wired.
|
|
335
|
+
detector = if %i[after_message add_message].all? { |m| chat.respond_to?(m) }
|
|
336
|
+
repeat = state.profile.limits[:max_tool_repeat] || Insika::AgentProfile::DEFAULT_LIMITS[:max_tool_repeat]
|
|
337
|
+
Insika::LoopDetector.new(chat: chat, limit: repeat, emit: emit) if repeat >= 2
|
|
338
|
+
end
|
|
339
|
+
|
|
219
340
|
chat.before_tool_call do |tool_call|
|
|
220
341
|
# call<->decorator correlation (side-effects/skip) — 1st line.
|
|
221
342
|
state.current_tool_call = tool_call
|
|
@@ -224,9 +345,13 @@ module Insika
|
|
|
224
345
|
tool_calls += 1
|
|
225
346
|
if tool_calls > max_tool_calls
|
|
226
347
|
raise Insika::TimeoutError.new("tool call limit exceeded (#{max_tool_calls})",
|
|
227
|
-
|
|
348
|
+
stage: :tool_limit)
|
|
228
349
|
end
|
|
229
350
|
|
|
351
|
+
# AFTER the count, BEFORE the call runs — a post-warning
|
|
352
|
+
# repeat raises here, so the stubborn loop pays for no extra call.
|
|
353
|
+
detector&.tool_call(tool_call.name, tool_call.arguments)
|
|
354
|
+
|
|
230
355
|
# :tool pair: RubyLLM's callbacks are additive — the altered subject
|
|
231
356
|
# feeds later hooks and the events, but does not rewrite the call the
|
|
232
357
|
# model executes. A hook exception here aborts the turn.
|
|
@@ -246,9 +371,16 @@ module Insika
|
|
|
246
371
|
end
|
|
247
372
|
|
|
248
373
|
chat.after_tool_result do |result|
|
|
374
|
+
# the RAW result — the only place a Tool::Halt (halt_when) is
|
|
375
|
+
# still recognizable, and a halted batch must receive no intervention.
|
|
376
|
+
detector&.tool_result(result)
|
|
249
377
|
result = @hooks.run_after(:tool, result)
|
|
250
378
|
emit.call(:tool_result, { name: state.current_tool_name, result: result.to_s })
|
|
251
379
|
end
|
|
380
|
+
|
|
381
|
+
# the intervention appends at the batch boundary (the Nth tool
|
|
382
|
+
# result closing) — never between two tool results of one batch.
|
|
383
|
+
chat.after_message { |message| detector.message_ended(message) } if detector
|
|
252
384
|
end
|
|
253
385
|
end
|
|
254
386
|
end
|
|
@@ -109,6 +109,22 @@ module Insika
|
|
|
109
109
|
nil
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# Deletes EVERY checkpoint + side-effect record of the task (WS8 retention:
|
|
113
|
+
# a purged task's durability trail goes with it — no orphaned keys).
|
|
114
|
+
# -> count of records removed.
|
|
115
|
+
def purge(task_id)
|
|
116
|
+
removed = 0
|
|
117
|
+
checkpoint_turns(task_id).each do |n|
|
|
118
|
+
@store.delete(SCOPE, checkpoint_key(task_id, n))
|
|
119
|
+
removed += 1
|
|
120
|
+
end
|
|
121
|
+
sideeffect_turns(task_id).each do |n|
|
|
122
|
+
@store.delete(SCOPE, sideeffects_key(task_id, n))
|
|
123
|
+
removed += 1
|
|
124
|
+
end
|
|
125
|
+
removed
|
|
126
|
+
end
|
|
127
|
+
|
|
112
128
|
private
|
|
113
129
|
|
|
114
130
|
def checkpoint_key(task_id, turn)
|