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
data/lib/insika/server/boot.rb
CHANGED
|
@@ -52,12 +52,12 @@ module Insika
|
|
|
52
52
|
Sync { do_recovery }
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# Task recovery THEN delegation recovery
|
|
55
|
+
# Task recovery THEN delegation recovery: the delegation
|
|
56
56
|
# sweep re-delivers completed-but-undelivered async delegations, and depends
|
|
57
57
|
# on the task sweep having re-dispatched any in-flight children first. Both
|
|
58
58
|
# create task fibers, so both must run inside the reactor scope of run_recovery.
|
|
59
59
|
#
|
|
60
|
-
# The TASK sweep is additionally gated per boot generation
|
|
60
|
+
# The TASK sweep is additionally gated per boot generation:
|
|
61
61
|
# its "orphaned :running" test cannot see a sibling worker's live fiber, so
|
|
62
62
|
# only the worker that claims the generation sweeps — the others would steal
|
|
63
63
|
# in-flight turns. The delegation and channel sweeps stay ungated: each of
|
|
@@ -89,7 +89,7 @@ module Insika
|
|
|
89
89
|
log("boot: delegations re-delivered — #{Array(result && result[:delivered]).size}")
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
#
|
|
92
|
+
# replies a previous process committed but never handed to the
|
|
93
93
|
# channel. Runs AFTER the task recovery for the same reason the delegation
|
|
94
94
|
# sweep does — a resumed turn writes its own outbox record at its terminal, and
|
|
95
95
|
# sweeping first would miss it.
|
|
@@ -108,7 +108,7 @@ module Insika
|
|
|
108
108
|
return if @wiring.durable?
|
|
109
109
|
|
|
110
110
|
log("boot: WARNING — EPHEMERAL backend (no INSIKA_DB): recovery will " \
|
|
111
|
-
"not resume anything after a restart
|
|
111
|
+
"not resume anything after a restart.")
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
def log(message)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# the /v1 transport as a VALUE the host app mounts, instead of a
|
|
4
4
|
# server the engine starts.
|
|
5
5
|
#
|
|
6
6
|
# mount Insika::Server.rack_app(INSIKA, token: ENV.fetch("INSIKA_TOKEN")), at: "/ai"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# server boot now calls this instead of inlining it, which is what keeps the two
|
|
12
12
|
# from drifting.
|
|
13
13
|
#
|
|
14
|
-
# The Studio is deliberately NOT part of this (the embed contract
|
|
14
|
+
# The Studio is deliberately NOT part of this (the embed contract): it is
|
|
15
15
|
# a class-level singleton, so it is one per process, and a host that wants the
|
|
16
16
|
# operator UI mounts `Studio::App` itself and accepts that limitation.
|
|
17
17
|
|
|
@@ -42,29 +42,42 @@ module Insika
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
attr_reader :token
|
|
45
|
+
attr_reader :graph
|
|
46
|
+
# the frozen criterion, memoized when the relay runs in shadow —
|
|
47
|
+
# nil otherwise. Loaded ONCE at boot (the refusal below), then shared.
|
|
48
|
+
attr_reader :criterion
|
|
45
49
|
|
|
46
50
|
def app
|
|
51
|
+
tenancy = @config[:tenancy] || ENV["INSIKA_TENANCY"] || "single_tenant"
|
|
52
|
+
# WS1: the token store is handed over ONLY in multi_tenant mode — in
|
|
53
|
+
# single_tenant the classic gateway token is the only credential
|
|
54
|
+
# (passing the store would silently widen the surface).
|
|
55
|
+
store = tenancy == "multi_tenant" ? @graph.token_store : nil
|
|
47
56
|
@app ||= Insika::Server::App.new(
|
|
48
57
|
command_bus: @graph.bus, event_stream: @graph.event_stream,
|
|
49
58
|
session_store: @graph.session_store, task_store: @graph.task_store,
|
|
50
59
|
pending_action_store: @graph.pending_action_store,
|
|
51
60
|
provisioner: Insika::PackImporter.new(bus: @graph.bus, profiles: @graph.profiles),
|
|
52
61
|
# GET /v1/agents/:id — the read-only capability view a case's `requires`
|
|
53
|
-
# resolves against
|
|
62
|
+
# resolves against.
|
|
54
63
|
profiles: @graph.profiles,
|
|
55
|
-
#
|
|
64
|
+
# the OSS onboarding surface (start.md + models.json + docs).
|
|
56
65
|
# This is the primary "build my first agent" target — models.json reports the
|
|
57
66
|
# DSL's stores + the agents this process serves (each id IS the `model`).
|
|
58
67
|
onboarding: build_onboarding,
|
|
59
|
-
#
|
|
68
|
+
# GET /v1/workflows + POST /v1/workflows/:name, opt-in by
|
|
60
69
|
# injection like every other edge — nil when the system declares none,
|
|
61
70
|
# so the routes simply do not exist (404, parity).
|
|
62
71
|
workflow_registry: (@graph.workflow_registry if workflows?),
|
|
63
|
-
#
|
|
72
|
+
# the bundled relay, when the env turns it on. Same rule as the
|
|
64
73
|
# OTEL bridge — a feature only `config.ru` can reach is a feature the
|
|
65
74
|
# docs are half-true about.
|
|
66
75
|
channels: (@graph.channel_registry if channels?),
|
|
67
|
-
config: { gateway_token: @token }.merge(@config)
|
|
76
|
+
config: { gateway_token: @token, tenancy: tenancy }.merge(@config),
|
|
77
|
+
token_store: store,
|
|
78
|
+
outcome_store: @graph.outcome_store,
|
|
79
|
+
# a 500's error_ref must be findable in the process log.
|
|
80
|
+
logger: $stdout
|
|
68
81
|
)
|
|
69
82
|
end
|
|
70
83
|
|
|
@@ -79,6 +92,17 @@ module Insika
|
|
|
79
92
|
allow_http: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_HTTP"]),
|
|
80
93
|
allow_private: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_PRIVATE"])
|
|
81
94
|
)
|
|
95
|
+
# NO criterion, no shadow — enforced at boot, not per
|
|
96
|
+
# turn. Raises ConfigError when the file is missing, unparseable, or
|
|
97
|
+
# incomplete; the criterion is memoized here and injected into
|
|
98
|
+
# ChannelDelivery (its sha stamps every pair our half records).
|
|
99
|
+
if relay&.shadow?
|
|
100
|
+
criterion_path = Insika::EnvSchema.read("INSIKA_PARITY_CRITERION")
|
|
101
|
+
raise Insika::ConfigError, "shadow mode requires INSIKA_PARITY_CRITERION (the frozen criterion)" if criterion_path.nil?
|
|
102
|
+
|
|
103
|
+
@criterion = Insika::Parity::Criterion.load(criterion_path)
|
|
104
|
+
@graph.channel_delivery&.criterion_sha = @criterion.sha
|
|
105
|
+
end
|
|
82
106
|
@graph.channel_registry.register(relay.id, relay) if relay
|
|
83
107
|
|
|
84
108
|
widget = Insika::Channels::Web.from_env(
|
|
@@ -4,9 +4,8 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Server
|
|
7
|
-
# OpenAI Responses edge adapter (`/v1/responses`) — the contract that
|
|
8
|
-
# OpenClaw gateway consumers already speak
|
|
9
|
-
# `CoreServices::OpenclawDispatcher`). Phase 6, Step A.
|
|
7
|
+
# OpenAI Responses edge adapter (`/v1/responses`) — the contract that
|
|
8
|
+
# OpenClaw gateway consumers already speak.
|
|
10
9
|
#
|
|
11
10
|
# PURE module (no state, no framework): (a) translates the OpenAI
|
|
12
11
|
# Responses request → `:send_message` payload; (b) maps each turn Event →
|
|
@@ -26,7 +25,7 @@ module Insika
|
|
|
26
25
|
# `origin` is the consumer declaring WHO wrote the input it is sending. It
|
|
27
26
|
# matters here more than anywhere: this adapter's `input` is a STRING the
|
|
28
27
|
# consumer already composed out of context blocks plus the customer's text
|
|
29
|
-
# (`<memoria> …`, `<
|
|
28
|
+
# (`<memoria> …`, `<store_cep_required> …`), so a transcript reader cannot
|
|
30
29
|
# tell the two apart — the first refinement run over real traffic reported 219
|
|
31
30
|
# "the customer repeated themselves" that were the engine reading its own
|
|
32
31
|
# fragment back. A consumer that sends `origin: "engine"` on a composed turn
|
|
@@ -41,10 +40,45 @@ module Insika
|
|
|
41
40
|
raise Insika::ValidationError, "user missing" if user.strip.empty?
|
|
42
41
|
|
|
43
42
|
message = extract_input(body[:input])
|
|
44
|
-
raise Insika::ValidationError, "input empty" if message.strip.empty?
|
|
45
43
|
|
|
46
44
|
out = { agent: agent.strip, user: user, message: message }
|
|
47
45
|
(origin = Insika::MessageOrigin.parse!(body[:origin])) && (out[:origin] = origin)
|
|
46
|
+
# WS8: the optional customer_key — per-customer memory scope + purge handle
|
|
47
|
+
(customer = Insika::Coercion.presence(body[:customer])) && (out[:customer] = customer)
|
|
48
|
+
# WS9: the multimodal OPENAI shape — `input` as an array of content parts
|
|
49
|
+
# ({type: text/image/audio}) is preserved additively alongside the
|
|
50
|
+
# joined text; a string input stays byte-identical to before. The
|
|
51
|
+
# CONTRACT is enforced here (422) — the engine stays lenient.
|
|
52
|
+
raw = body[:input]
|
|
53
|
+
if raw.is_a?(Array)
|
|
54
|
+
unless Insika::Media.well_formed?(raw)
|
|
55
|
+
raise Insika::ValidationError,
|
|
56
|
+
"malformed content part — each part must be {type: text|image|audio} with text/url"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
normalized = Insika::Media.parts(raw).map do |p|
|
|
60
|
+
{ "type" => p.type, "text" => p.text, "url" => p.url }.compact
|
|
61
|
+
end
|
|
62
|
+
out[:parts] = normalized unless normalized.empty?
|
|
63
|
+
end
|
|
64
|
+
# The turn needs SOMETHING to be about — text, or media the engine will
|
|
65
|
+
# turn into text (a voice note) or show the model (a photo). Checked
|
|
66
|
+
# after the parts are known, because the anchor use case (a WhatsApp
|
|
67
|
+
# voice note, no caption) carries no text at all and joining only the
|
|
68
|
+
# text parts made it a 422 at the door.
|
|
69
|
+
if message.strip.empty? && Array(out[:parts]).none? { |p| p["type"] != "text" }
|
|
70
|
+
raise Insika::ValidationError, "input empty"
|
|
71
|
+
end
|
|
72
|
+
# WS9: `source` marks pre-transcribed voice text; anything else is refused.
|
|
73
|
+
unless body[:source].nil? || body[:source].to_s == "voice"
|
|
74
|
+
raise Insika::ValidationError, 'source must be "voice"'
|
|
75
|
+
end
|
|
76
|
+
out[:source] = body[:source].to_s if Insika::Coercion.presence(body[:source])
|
|
77
|
+
# WS9 (saída): the channel declares which generated media it can
|
|
78
|
+
# RECEIVE ({ capabilities: ["image_output", "audio_output"] }). Additive
|
|
79
|
+
# + additive sibling on the completed frame; the exact capabilities are
|
|
80
|
+
# validated at the boundary (message_flow), not here.
|
|
81
|
+
(channel = body[:channel]) && (out[:channel] = channel)
|
|
48
82
|
out
|
|
49
83
|
end
|
|
50
84
|
|
|
@@ -84,8 +118,8 @@ module Insika
|
|
|
84
118
|
# The provider's reasoning. Internal unless the AGENT opted in
|
|
85
119
|
# (`edge_stream thinking: true`), which tags the event. Even then it does
|
|
86
120
|
# NOT become answer text: it gets the Responses reasoning frame, so a
|
|
87
|
-
# consumer that only accumulates `output_text` deltas —
|
|
88
|
-
#
|
|
121
|
+
# consumer that only accumulates `output_text` deltas — a dispatcher
|
|
122
|
+
# that turns them into one WhatsApp message — is unaffected,
|
|
89
123
|
# and one that renders reasoning has something to render.
|
|
90
124
|
if public_delta(event)
|
|
91
125
|
sse("response.reasoning_summary_text.delta",
|
|
@@ -108,13 +142,19 @@ module Insika
|
|
|
108
142
|
{ type: "insika.intermediate.delta", delta: event.data[:delta].to_s })
|
|
109
143
|
end
|
|
110
144
|
when :guardrail_blocked, :guardrail_flagged
|
|
111
|
-
#
|
|
145
|
+
# audit events with no OpenAI Responses counterpart. On a BLOCK
|
|
112
146
|
# the safe reply still reaches the consumer through the normal :content
|
|
113
147
|
# deltas + :task_completed path (the turn completes gracefully), so there
|
|
114
148
|
# is nothing extra to translate here — the events live in /v1/events + the
|
|
115
149
|
# Studio + the trace. Explicit (not a fall-through) to keep the closed
|
|
116
150
|
# catalog honest.
|
|
117
151
|
nil
|
|
152
|
+
when :ttft
|
|
153
|
+
# the live TTFB signal (WS6, INSIKA_TURN_TIMING opt-in): the provider's
|
|
154
|
+
# ms-to-first-token, emitted when the first content chunk arrives.
|
|
155
|
+
# Namespaced insika.* — no OpenAI Responses counterpart; unknown types
|
|
156
|
+
# are ignored, the safe failure.
|
|
157
|
+
sse("insika.ttft", { type: "insika.ttft", ttft_ms: event.data[:ttft_ms].to_i })
|
|
118
158
|
end
|
|
119
159
|
end
|
|
120
160
|
|
|
@@ -133,9 +173,18 @@ module Insika
|
|
|
133
173
|
response[:usage] = usage.reject { |k, _| k.to_s == "model" }
|
|
134
174
|
response[:model] = model if model
|
|
135
175
|
end
|
|
136
|
-
# Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING
|
|
176
|
+
# Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING). Absent
|
|
137
177
|
# by default — a non-standard sibling used only for TTFB diagnostics.
|
|
138
178
|
(timing = event.data[:timing]) && (response[:timing] = timing)
|
|
179
|
+
# WS5 stuck signal: an additive sibling the terminal frame carries when the
|
|
180
|
+
# agent ended the turn declaring it cannot proceed. Consumers that only read
|
|
181
|
+
# the OpenAI-shaped response.use it to run their escalation ("stuck" means
|
|
182
|
+
# what they decide it means, never the engine's business).
|
|
183
|
+
(outcome = event.data[:outcome]) && (response[:outcome] = outcome.to_s)
|
|
184
|
+
# WS9 (saída): generated media parts (image/audio clips) ride the
|
|
185
|
+
# completed frame additively next to the text — absent when none were
|
|
186
|
+
# generated. The base64 bytes are the consumer's to render/upload.
|
|
187
|
+
(parts = event.data[:output_parts]) && (response[:output_parts] = parts)
|
|
139
188
|
sse("response.completed", { type: "response.completed", response: response })
|
|
140
189
|
end
|
|
141
190
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Server
|
|
8
|
+
# Edge resolution for WS1 (multi-tenant): `Authorization: Bearer <token>` ->
|
|
9
|
+
# a principal `{ role:, tenant_id: }`, resolved BEFORE the routes. Two modes,
|
|
10
|
+
# one gate:
|
|
11
|
+
#
|
|
12
|
+
# single_tenant (default) — no token store: the classic single operator
|
|
13
|
+
# credential (config[:gateway_token]) is the only thing that resolves.
|
|
14
|
+
# multi_tenant — tokens live in the TokenStore (per-tenant + operator).
|
|
15
|
+
# A configured gateway_token STILL resolves as operator (an existing
|
|
16
|
+
# deployment switching modes keeps its credential — additive, never
|
|
17
|
+
# a second-class path).
|
|
18
|
+
#
|
|
19
|
+
# Fail-closed BY CONSTRUCTION: no store and no configured token -> :disabled
|
|
20
|
+
# (503, never open). A revoked or unknown token -> :unauthorized. Pure module,
|
|
21
|
+
# testable without a Rack env.
|
|
22
|
+
module TenantAuth
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# gateway_token: config[:gateway_token] | nil. token_store: TokenStore |
|
|
26
|
+
# nil. header: raw Authorization value.
|
|
27
|
+
# -> :disabled | :unauthorized | { role: "operator"|"tenant", tenant_id: }
|
|
28
|
+
def check(gateway_token, token_store, header)
|
|
29
|
+
# Fail-closed FIRST (the construction rule): with no store AND no
|
|
30
|
+
# configured token the gateway is DISABLED (503) however the request
|
|
31
|
+
# looks — never "401: who are you facing a door that does not exist".
|
|
32
|
+
# A token_store present means the gateway IS configured (multi_tenant),
|
|
33
|
+
# with or without the legacy gateway token.
|
|
34
|
+
return :disabled if token_store.nil? && (gateway_token.nil? || gateway_token.empty?)
|
|
35
|
+
|
|
36
|
+
provided = header.to_s[/\ABearer (.+)\z/, 1]
|
|
37
|
+
return :unauthorized if provided.nil?
|
|
38
|
+
|
|
39
|
+
if token_store
|
|
40
|
+
record = token_store.resolve(provided)
|
|
41
|
+
unless record
|
|
42
|
+
# store miss -> the legacy gateway token still resolves as operator
|
|
43
|
+
# (an existing deployment switching modes keeps its credential).
|
|
44
|
+
return :unauthorized if gateway_token.nil? || gateway_token.empty?
|
|
45
|
+
return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
|
|
46
|
+
|
|
47
|
+
return { role: "operator", tenant_id: nil }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return { role: record.role.to_s, tenant_id: record.tenant_id }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# classic mode (no store): the gateway token is the only credential.
|
|
54
|
+
# Constant-time comparison: the operator token doesn't leak via timing.
|
|
55
|
+
return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
|
|
56
|
+
|
|
57
|
+
{ role: "operator", tenant_id: nil }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/insika/session_actor.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Insika
|
|
|
17
17
|
# Executor) is also born on the supervisor; the SessionActor only AWAITS it to
|
|
18
18
|
# serialize.
|
|
19
19
|
#
|
|
20
|
-
#
|
|
20
|
+
# it is also where an inbound message for a BUSY session is routed.
|
|
21
21
|
# That decision belongs here and nowhere else — this is already the object that
|
|
22
22
|
# owns "one turn at a time for this session". Putting it in the HTTP handler
|
|
23
23
|
# would duplicate the invariant; putting it in the Executor would mix turn
|
|
@@ -40,12 +40,16 @@ module Insika
|
|
|
40
40
|
#
|
|
41
41
|
# `policy` (a QueuePolicy) opens the debounce window for this turn; nil or a
|
|
42
42
|
# policy without a window behaves exactly as before — dequeued and run at once.
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
#
|
|
44
|
+
# `timing` is the channel clock a channel turn allocated at 202
|
|
45
|
+
# acceptance and already stamped `:inbound`; it rides the queue so the debounce
|
|
46
|
+
# window and the FIFO wait land INSIDE first_balloon_ms.
|
|
47
|
+
def enqueue(task, profile:, resume_from: nil, policy: nil, timing: nil)
|
|
48
|
+
@queue.enqueue([task, profile, resume_from, policy, timing])
|
|
45
49
|
task.id
|
|
46
50
|
end
|
|
47
51
|
|
|
48
|
-
#
|
|
52
|
+
# merge a fragment into the turn waiting at the door.
|
|
49
53
|
# -> the task id it joined, or nil when there is nothing to merge into (no
|
|
50
54
|
# pending turn, the window has closed, or the turn already started). nil is
|
|
51
55
|
# the caller's signal to create a task of its own.
|
|
@@ -99,12 +103,12 @@ module Insika
|
|
|
99
103
|
|
|
100
104
|
def run_loop
|
|
101
105
|
loop do
|
|
102
|
-
task, profile, resume_from, policy = @queue.dequeue # blocks when empty
|
|
106
|
+
task, profile, resume_from, policy, timing = @queue.dequeue # blocks when empty
|
|
103
107
|
task = hold_at_the_door(task, policy)
|
|
104
108
|
@running = true
|
|
105
109
|
@current_task = task
|
|
106
110
|
begin
|
|
107
|
-
@executor.run_serial(task, profile: profile, resume_from: resume_from)
|
|
111
|
+
@executor.run_serial(task, profile: profile, resume_from: resume_from, timing: timing)
|
|
108
112
|
rescue StandardError
|
|
109
113
|
# run_serial already maps turn errors; this rescue is defense: an
|
|
110
114
|
# unexpected error must NEVER bring down the session loop (Async::Stop <
|
|
@@ -117,7 +121,7 @@ module Insika
|
|
|
117
121
|
end
|
|
118
122
|
end
|
|
119
123
|
|
|
120
|
-
#
|
|
124
|
+
# the debounce window. Sleeps on the LOOP's fiber, never on the
|
|
121
125
|
# request's, so the POST is acked immediately and the platform does not retry.
|
|
122
126
|
# Returns the task to run (re-read from the store when fragments merged into it,
|
|
123
127
|
# since the in-memory Task is a frozen snapshot of an older message).
|
data/lib/insika/session_store.rb
CHANGED
|
@@ -23,7 +23,14 @@ module Insika
|
|
|
23
23
|
KEY_PREFIX = "session:"
|
|
24
24
|
|
|
25
25
|
Session = Data.define(:id, :messages, :vars, :memory_refs,
|
|
26
|
-
:created_at, :updated_at)
|
|
26
|
+
:created_at, :updated_at, :briefing, :evidence) do
|
|
27
|
+
# Trailing members with defaults: an old record without the "briefing" /
|
|
28
|
+
# "evidence" keys reads as empty/nil without a migration.
|
|
29
|
+
def initialize(id:, messages:, vars:, memory_refs:, created_at:, updated_at:,
|
|
30
|
+
briefing: nil, evidence: nil)
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
27
34
|
|
|
28
35
|
# store: any Insika::Store (Memory, SQLite, ...) — injected by the
|
|
29
36
|
# composition root (config/wiring.rb). SessionStore does not know the
|
|
@@ -44,6 +51,8 @@ module Insika
|
|
|
44
51
|
"messages" => [],
|
|
45
52
|
"vars" => deep_stringify(vars),
|
|
46
53
|
"memory_refs" => [],
|
|
54
|
+
"briefing" => { "fields" => {}, "next_step" => nil },
|
|
55
|
+
"evidence" => { "ids" => [], "ungrounded" => 0 },
|
|
47
56
|
"created_at" => now,
|
|
48
57
|
"updated_at" => now
|
|
49
58
|
}
|
|
@@ -61,7 +70,7 @@ module Insika
|
|
|
61
70
|
# fiber, without a lock. Each message gets an "at" (ISO8601 UTC) if not
|
|
62
71
|
# provided. NotFoundError if the session does not exist.
|
|
63
72
|
#
|
|
64
|
-
# CONCURRENCY LIMITATION (
|
|
73
|
+
# CONCURRENCY LIMITATION (R2c): the RMW (read record -> += -> set) is
|
|
65
74
|
# atomic ONLY because the SessionActor serializes turns of the same session
|
|
66
75
|
# (one owner at a time). That serialization exists solely in SUPERVISED mode
|
|
67
76
|
# (the actor loop lives on the supervisor). Two concurrent send_message on the
|
|
@@ -89,6 +98,58 @@ module Insika
|
|
|
89
98
|
to_session(record)
|
|
90
99
|
end
|
|
91
100
|
|
|
101
|
+
# appends this turn's evidence (ids + ungrounded delta) to the
|
|
102
|
+
# session record. RMW like append_messages — the SessionActor serializes
|
|
103
|
+
# same-session turns; the copy is in the method comment.
|
|
104
|
+
def append_evidence(id, ids:, ungrounded:)
|
|
105
|
+
record = fetch!(id)
|
|
106
|
+
ev = record["evidence"] ||= { "ids" => [], "ungrounded" => 0 }
|
|
107
|
+
fresh = (ev["ids"] + Array(ids).map(&:to_s).reject(&:empty?)).uniq.last(EvidenceLedger::MAX_IDS)
|
|
108
|
+
ev["ids"] = fresh
|
|
109
|
+
ev["ungrounded"] = ev["ungrounded"].to_i + ungrounded.to_i
|
|
110
|
+
record["updated_at"] = timestamp
|
|
111
|
+
@store.set(SCOPE, key_for(id), record)
|
|
112
|
+
to_session(record)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# -> Session. Upsert ONE briefing field. The pack owns the schema; the
|
|
116
|
+
# engine validates nothing about field NAMES here (the tools do, at the
|
|
117
|
+
# write edge). value is a String (anything else -> to_s); a BLANK value
|
|
118
|
+
# (after strip) REMOVES the key — absence means "not yet asked" (
|
|
119
|
+
# D4). NotFoundError if the session does not exist.
|
|
120
|
+
#
|
|
121
|
+
# CONCURRENCY NOTE: an unlocked RMW (read -> mutate -> set), like
|
|
122
|
+
# append_messages — but the SessionActor argument does NOT apply here. A
|
|
123
|
+
# briefing writer is a system tool, never enveloped, and with
|
|
124
|
+
# tool_concurrency > 1 the gem runs each call in its OWN fiber — outside the
|
|
125
|
+
# actor's per-session turn serialization. The RMW is still safe, for a
|
|
126
|
+
# different reason: nothing in the read/mutate/set path suspends. Store
|
|
127
|
+
# get/set are synchronous (the SQLite write semaphore is a non-yielding fast
|
|
128
|
+
# path when free), and a fiber only switches at a scheduler suspension point
|
|
129
|
+
# — so no other writer can interleave mid-RMW (measured: N concurrent
|
|
130
|
+
# writers lose nothing). It holds ONLY while that path never suspends; an
|
|
131
|
+
# async store (a real yield in get/set) would need a lock or CAS.
|
|
132
|
+
def update_briefing(id, field:, value:)
|
|
133
|
+
record = fetch!(id)
|
|
134
|
+
briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
|
|
135
|
+
value = Coercion.presence(Coercion.utf8(value.to_s))
|
|
136
|
+
value ? briefing["fields"][field.to_s] = value : briefing["fields"].delete(field.to_s)
|
|
137
|
+
record["updated_at"] = timestamp
|
|
138
|
+
@store.set(SCOPE, key_for(id), record)
|
|
139
|
+
to_session(record)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# -> Session. Upsert the agreed next step; a blank text clears to nil
|
|
143
|
+
# NotFoundError if absent.
|
|
144
|
+
def set_next_step(id, text:)
|
|
145
|
+
record = fetch!(id)
|
|
146
|
+
briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
|
|
147
|
+
briefing["next_step"] = Coercion.presence(Coercion.utf8(text.to_s))
|
|
148
|
+
record["updated_at"] = timestamp
|
|
149
|
+
@store.set(SCOPE, key_for(id), record)
|
|
150
|
+
to_session(record)
|
|
151
|
+
end
|
|
152
|
+
|
|
92
153
|
# -> bool (delegates to the backend: false for a nonexistent id)
|
|
93
154
|
def delete(id)
|
|
94
155
|
@store.delete(SCOPE, key_for(id))
|
|
@@ -126,7 +187,9 @@ module Insika
|
|
|
126
187
|
vars: record["vars"],
|
|
127
188
|
memory_refs: record["memory_refs"],
|
|
128
189
|
created_at: record["created_at"],
|
|
129
|
-
updated_at: record["updated_at"]
|
|
190
|
+
updated_at: record["updated_at"],
|
|
191
|
+
briefing: record["briefing"] || { "fields" => {}, "next_step" => nil },
|
|
192
|
+
evidence: record["evidence"]
|
|
130
193
|
)
|
|
131
194
|
end
|
|
132
195
|
|
|
@@ -13,7 +13,7 @@ module Insika
|
|
|
13
13
|
SCOPE = "settings"
|
|
14
14
|
KEY = "general"
|
|
15
15
|
|
|
16
|
-
# STRICT config, settings layer (
|
|
16
|
+
# STRICT config, settings layer (— "no silent config compat: every
|
|
17
17
|
# schema migration explicit"). The settings record carries a `schema_version`;
|
|
18
18
|
# every shape change is a numbered migration here, applied ONLY by the explicit
|
|
19
19
|
# `migrate!` (Studio settings saves never silently reinterpret old-shaped data).
|
|
@@ -31,7 +31,17 @@ module Insika
|
|
|
31
31
|
"turn_timeout" => 120,
|
|
32
32
|
"tool_timeout" => 30,
|
|
33
33
|
"compaction" => { "enabled" => false, "keep_last" => 20 },
|
|
34
|
-
#
|
|
34
|
+
# Data lifecycle (WS8, phase 2): the RETENTION window in days. The
|
|
35
|
+
# tick's Retention sweep purges sessions (+traces), terminal tasks
|
|
36
|
+
# (+checkpoints), memory cells and outcomes older than this. nil/0 =
|
|
37
|
+
# OFF (parity — nothing is ever swept by default).
|
|
38
|
+
"retention_days" => nil,
|
|
39
|
+
# memory TTL. Integer = default for every cell; Hash =
|
|
40
|
+
# per-tenant { "<tenant>" => days, "*" => days } (ops-authored). nil = OFF.
|
|
41
|
+
# Swept by the Retention tick on its OWN daily claim, NOT gated by
|
|
42
|
+
# retention_days (D5). Additive key — reads overlay DEFAULTS.
|
|
43
|
+
"memory_ttl_days" => nil,
|
|
44
|
+
# LLM config v2. Platform-wide model layer, resolved by the
|
|
35
45
|
# ModelResolver under an agent that pins no model of its own:
|
|
36
46
|
# default_model/default_provider -> the platform default (Chat > Agent > HERE)
|
|
37
47
|
# fallback_models -> ordered chain ["provider/model" | "model", ...] tried
|
|
@@ -42,13 +52,13 @@ module Insika
|
|
|
42
52
|
"default_provider" => nil,
|
|
43
53
|
"fallback_models" => [],
|
|
44
54
|
"utility_model" => nil,
|
|
45
|
-
# Reasoning control (
|
|
55
|
+
# Reasoning control (4-layer: Chat > Agent > Model > Global). `thinking`
|
|
46
56
|
# is the GLOBAL default (off/on/low/medium/high; nil = provider default);
|
|
47
57
|
# `model_params` is the PER-MODEL layer, a map "<provider/model>"|"<model>" ->
|
|
48
58
|
# { "thinking" => ... }. Both resolved by the ModelResolver.
|
|
49
59
|
"thinking" => nil,
|
|
50
60
|
"model_params" => {},
|
|
51
|
-
# Evals (
|
|
61
|
+
# Evals (panel by). The GRADERS are platform config, so
|
|
52
62
|
# the operator picks them in the Studio instead of remembering a CLI flag:
|
|
53
63
|
# judges -> [{ "model" =>, "provider" => }, …]. [] = deterministic
|
|
54
64
|
# asserts only (rubric'd cases read as judge_pending).
|
|
@@ -66,7 +76,7 @@ module Insika
|
|
|
66
76
|
"quorum" => 1,
|
|
67
77
|
"tolerance" => 0.05
|
|
68
78
|
},
|
|
69
|
-
# Edge limits
|
|
79
|
+
# Edge limits — the platform layer of the EdgeLimiter.
|
|
70
80
|
# nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
|
|
71
81
|
# chat_rate_window (s); agent_token_ceiling = total tokens per agent per
|
|
72
82
|
# agent_token_window (s). limit_response overrides the safe reply.
|