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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# The CONVERSATION footprint of a session, purged (WS8/LGPD). Shared by
|
|
6
|
+
# `forget_customer` and `delete_tenant_data` because "erase this person"
|
|
7
|
+
# and "erase this tenant" differ only in WHICH sessions they name — what a
|
|
8
|
+
# session leaves behind is the same list, and a second copy of that list is
|
|
9
|
+
# a second thing to forget to update.
|
|
10
|
+
#
|
|
11
|
+
# Deleting the session record alone is NOT erasure: the customer's own text
|
|
12
|
+
# lives in the task's persisted command payload, the whole transcript lives
|
|
13
|
+
# in the turn's checkpoints, and the answer as it was handed to the channel
|
|
14
|
+
# lives in the outbox record's payload. All four go together or none of them
|
|
15
|
+
# counts.
|
|
16
|
+
#
|
|
17
|
+
# A task is deleted whatever its status — this is a deletion order, not the
|
|
18
|
+
# retention sweep (which spares live tasks on purpose). The stores it needs
|
|
19
|
+
# beyond the session are optional (deployment components): a graph without
|
|
20
|
+
# them purges what it has and reports zero for the rest.
|
|
21
|
+
module SessionPurge
|
|
22
|
+
# ids: the session ids to erase. -> { tasks:, checkpoints:, deliveries:, pairs: }
|
|
23
|
+
def purge_sessions(ids)
|
|
24
|
+
ids = Array(ids).map(&:to_s)
|
|
25
|
+
return { tasks: 0, checkpoints: 0, deliveries: 0, pairs: 0 } if ids.empty?
|
|
26
|
+
|
|
27
|
+
tasks, checkpoints, model_visible = purge_tasks_of(ids)
|
|
28
|
+
deliveries = @outbox_store ? @outbox_store.purge_sessions(ids) : 0
|
|
29
|
+
pairs = @shadow_pairs ? @shadow_pairs.purge_sessions(ids) : 0
|
|
30
|
+
ids.each do |id|
|
|
31
|
+
@tool_trace_store&.clear(id)
|
|
32
|
+
@context_trace_store&.clear(id)
|
|
33
|
+
@session_store.delete(id)
|
|
34
|
+
end
|
|
35
|
+
{ tasks: tasks, checkpoints: checkpoints, model_visible: model_visible,
|
|
36
|
+
deliveries: deliveries, pairs: pairs }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# -> [tasks removed, checkpoint records removed, model-visible records removed].
|
|
42
|
+
# the model-visible traces are transcripts — they die next to
|
|
43
|
+
# their checkpoints (the same single list, so "erase this person" cannot
|
|
44
|
+
# forget one half). The id list is SNAPSHOTTED (`to_a`) before the deletes:
|
|
45
|
+
# `each_id` enumerates the backend's keys lazily, and deleting under it
|
|
46
|
+
# would skip records.
|
|
47
|
+
def purge_tasks_of(ids)
|
|
48
|
+
return [0, 0, 0] unless @task_store
|
|
49
|
+
|
|
50
|
+
wanted = ids.each_with_object({}) { |id, acc| acc[id] = true }
|
|
51
|
+
tasks = 0
|
|
52
|
+
checkpoints = 0
|
|
53
|
+
model_visible = 0
|
|
54
|
+
@task_store.each_id.to_a.each do |task_id|
|
|
55
|
+
task = @task_store.find(task_id)
|
|
56
|
+
next unless task && wanted[task.session_id.to_s]
|
|
57
|
+
|
|
58
|
+
checkpoints += @checkpoint_store ? @checkpoint_store.purge(task_id) : 0
|
|
59
|
+
model_visible += @model_visible_trace_store ? @model_visible_trace_store.purge(task_id) : 0
|
|
60
|
+
@task_store.delete(task_id)
|
|
61
|
+
tasks += 1
|
|
62
|
+
end
|
|
63
|
+
[tasks, checkpoints, model_visible]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -6,7 +6,7 @@ module Insika
|
|
|
6
6
|
module Commands
|
|
7
7
|
# Control command: adjusts an agent's tools allow/denylist.
|
|
8
8
|
# `allow` nil = all (AgentProfile rule); `deny` always
|
|
9
|
-
# wins. `allow_groups
|
|
9
|
+
# wins. `allow_groups`: per-group allowlist, only
|
|
10
10
|
# overwritten if the key comes in the payload (otherwise preserved). Takes effect on the next
|
|
11
11
|
# dispatch (hot). -> AgentProfile.
|
|
12
12
|
class SetAgentTools
|
|
@@ -5,8 +5,15 @@ require "time"
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
7
|
# Control command: enables/disables a skill on N
|
|
8
|
-
# agents at once, adjusting each one's `profile.skills` allowlist
|
|
9
|
-
#
|
|
8
|
+
# agents at once, adjusting each one's `profile.skills` allowlist — and, with
|
|
9
|
+
# `eager_ids`, which of them keep the body in the prompt on EVERY turn
|
|
10
|
+
# (`profile.skills_eager`). Takes effect on the next dispatch (hot via
|
|
11
|
+
# ProfileSource). -> { name, enabled_for, eager_for, skipped_all, skipped_eager_all }.
|
|
12
|
+
#
|
|
13
|
+
# Both are per-agent decisions about the same skill, which is why they are one
|
|
14
|
+
# command: eagerness lives on the agent precisely BECAUSE the skill is shared, so
|
|
15
|
+
# the screen that says "which agents can load this" is the screen that says "and
|
|
16
|
+
# which of them always have it".
|
|
10
17
|
#
|
|
11
18
|
# Allowlist semantics (AgentProfile): nil = ALL, [] = none,
|
|
12
19
|
# [names] = subset. Important and deliberate consequence:
|
|
@@ -16,6 +23,11 @@ module Insika
|
|
|
16
23
|
# allowlist (destructive/surprising). These agents are left intact and
|
|
17
24
|
# go into `skipped_all`. To restrict, use an explicit :set_agent_tools/allowlist
|
|
18
25
|
# first.
|
|
26
|
+
#
|
|
27
|
+
# `skills_eager` is NOT allowlist semantics (nil = NONE, see
|
|
28
|
+
# SkillCatalog#eager_for), so the two fields are adjusted by different rules: nil
|
|
29
|
+
# there is an empty set that can simply be added to, and only the BLANKET `true`
|
|
30
|
+
# has the same "cannot remove one name from all" problem (-> skipped_eager_all).
|
|
19
31
|
class SetSkillAgents
|
|
20
32
|
def initialize(profile_source:, event_stream:)
|
|
21
33
|
@profile_source = profile_source
|
|
@@ -27,35 +39,46 @@ module Insika
|
|
|
27
39
|
name = AgentPayload.presence(p[:name])
|
|
28
40
|
raise Insika::ValidationError, "name is required" if name.nil?
|
|
29
41
|
raise Insika::ValidationError, "agent_ids must be a list" unless p[:agent_ids].nil? || p[:agent_ids].is_a?(Array)
|
|
42
|
+
raise Insika::ValidationError, "eager_ids must be a list" unless p[:eager_ids].nil? || p[:eager_ids].is_a?(Array)
|
|
30
43
|
|
|
31
44
|
wanted = Array(p[:agent_ids]).map(&:to_s)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
new_skills = next_skills(profile.skills, name, want)
|
|
38
|
-
|
|
39
|
-
if new_skills == :skip
|
|
40
|
-
skipped_all << profile.id
|
|
41
|
-
next
|
|
42
|
-
end
|
|
43
|
-
next if new_skills == profile.skills # no change
|
|
45
|
+
# eager_ids ABSENT means "this form does not manage eagerness" — leave every
|
|
46
|
+
# profile's setting alone. An empty ARRAY means "none of them", which is a real
|
|
47
|
+
# instruction and must be applied. `nil` and `[]` are not the same request.
|
|
48
|
+
eager_wanted = p[:eager_ids].nil? ? nil : Array(p[:eager_ids]).map(&:to_s)
|
|
49
|
+
result = { enabled_for: [], eager_for: [], skipped_all: [], skipped_eager_all: [] }
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
enabled_for << profile.id if want
|
|
47
|
-
end
|
|
51
|
+
@profile_source.all.each { |profile| apply(profile, name, wanted, eager_wanted, result) }
|
|
48
52
|
|
|
49
53
|
@event_stream.emit(Insika::Event.new(
|
|
50
54
|
type: :skill_agents_set,
|
|
51
|
-
data: { name: name, agent_ids: wanted,
|
|
55
|
+
data: { name: name, agent_ids: wanted, eager_ids: eager_wanted,
|
|
56
|
+
skipped_all: result[:skipped_all] }.compact,
|
|
52
57
|
meta: { at: Time.now.utc.iso8601 }
|
|
53
58
|
))
|
|
54
|
-
{ name: name
|
|
59
|
+
{ name: name }.merge(result)
|
|
55
60
|
end
|
|
56
61
|
|
|
57
62
|
private
|
|
58
63
|
|
|
64
|
+
def apply(profile, name, wanted, eager_wanted, result)
|
|
65
|
+
want = wanted.include?(profile.id)
|
|
66
|
+
new_skills = next_skills(profile.skills, name, want)
|
|
67
|
+
new_eager = next_eager(profile, name, eager_wanted, result)
|
|
68
|
+
|
|
69
|
+
if new_skills == :skip
|
|
70
|
+
result[:skipped_all] << profile.id
|
|
71
|
+
new_skills = profile.skills
|
|
72
|
+
end
|
|
73
|
+
result[:enabled_for] << profile.id if want
|
|
74
|
+
result[:eager_for] << profile.id if eager_wanted&.include?(profile.id)
|
|
75
|
+
return if new_skills == profile.skills && new_eager == profile.skills_eager
|
|
76
|
+
|
|
77
|
+
@profile_source.put(Insika::AgentProfile.build(
|
|
78
|
+
**profile.to_h.merge(skills: new_skills, skills_eager: new_eager)
|
|
79
|
+
))
|
|
80
|
+
end
|
|
81
|
+
|
|
59
82
|
# -> new allowlist | :skip (agent with nil that requested disable).
|
|
60
83
|
def next_skills(current, name, want)
|
|
61
84
|
if want
|
|
@@ -66,6 +89,24 @@ module Insika
|
|
|
66
89
|
Array(current).map(&:to_s) - [name]
|
|
67
90
|
end
|
|
68
91
|
end
|
|
92
|
+
|
|
93
|
+
# -> new skills_eager. nil eager_wanted = not managed by this call.
|
|
94
|
+
def next_eager(profile, name, eager_wanted, result)
|
|
95
|
+
current = profile.skills_eager
|
|
96
|
+
return current if eager_wanted.nil?
|
|
97
|
+
|
|
98
|
+
want = eager_wanted.include?(profile.id)
|
|
99
|
+
# Blanket `true` already includes every skill; removing ONE name from it would
|
|
100
|
+
# mean materializing the whole catalog as a list, the same destructive surprise
|
|
101
|
+
# `skipped_all` exists for.
|
|
102
|
+
if Coercion.truthy?(current)
|
|
103
|
+
result[:skipped_eager_all] << profile.id unless want
|
|
104
|
+
return current
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
list = current.nil? || current == false ? [] : Array(current).map(&:to_s)
|
|
108
|
+
want ? (list | [name]) : (list - [name])
|
|
109
|
+
end
|
|
69
110
|
end
|
|
70
111
|
end
|
|
71
112
|
end
|
|
@@ -45,7 +45,7 @@ module Insika
|
|
|
45
45
|
raise Insika::NotFoundError, "workflow '#{workflow}' not registered"
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# I/O by schema
|
|
48
|
+
# I/O by schema: the INPUT is validated SYNCHRONOUSLY —
|
|
49
49
|
# a non-conforming input is a WorkflowSchemaError (< ValidationError -> 422)
|
|
50
50
|
# with NO run created. `definition` reads the schema metadata without
|
|
51
51
|
# resolving the factory (stays out of the fiber). No-op without an input_schema.
|
|
@@ -25,7 +25,7 @@ module Insika
|
|
|
25
25
|
# what was sent. id never changes (rename = create+delete, out of scope).
|
|
26
26
|
merged = existing.to_h.merge(patch).merge(id: id)
|
|
27
27
|
profile = Insika::AgentProfile.build(**merged)
|
|
28
|
-
#
|
|
28
|
+
# definition-time cycle + depth check (an update may ADD
|
|
29
29
|
# subagents to an existing agent). Raises SubagentError before persisting.
|
|
30
30
|
validate_subagent_graph!(profile)
|
|
31
31
|
@profile_source.put(profile)
|
|
@@ -5,7 +5,7 @@ require "time"
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
7
|
# Writes (upserts) a DATA-defined tool into the ToolStore and RELOADS the overlay +
|
|
8
|
-
# catalog — takes effect without a restart
|
|
8
|
+
# catalog — takes effect without a restart. Rejects a name that collides with a
|
|
9
9
|
# code tool (R3). Validating the definition (format, method/url, types,
|
|
10
10
|
# placeholders) is the ToolStore/ToolDefinition's job. Secrets (headers) reconcile
|
|
11
11
|
# in the store; the event carries only the name (0 leakage). -> { name, definition }.
|
|
@@ -4,7 +4,7 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
|
-
# Control command: writes an eval case
|
|
7
|
+
# Control command: writes an eval case into the GoldenStore. This
|
|
8
8
|
# is what lets a domain owner add a case — above all its RUBRIC, the part of an eval
|
|
9
9
|
# that is plain language — without a checkout and a text editor.
|
|
10
10
|
#
|
|
@@ -5,10 +5,15 @@ require "yaml"
|
|
|
5
5
|
|
|
6
6
|
module Insika
|
|
7
7
|
module Commands
|
|
8
|
-
# Control command: writes a
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# Control command: writes a skill (complete SKILL.md) into
|
|
9
|
+
# the SkillStore and RELOADS the catalog — takes effect without a restart (hot).
|
|
10
|
+
# Validates the frontmatter (name required) before writing. -> { name, agent, updated_at }.
|
|
11
|
+
#
|
|
12
|
+
# `agent` (optional) writes into that agent's scope instead of the shared one: the
|
|
13
|
+
# SPECIALIZATION of a shared skill, or a skill private to one agent. Same `name`
|
|
14
|
+
# either way — the store position is the identity, so the frontmatter inside an
|
|
15
|
+
# override keeps saying the bare shared name and the level-1 catalog, load_skill,
|
|
16
|
+
# the labels and the events all keep displaying it.
|
|
12
17
|
class WriteSkill
|
|
13
18
|
def initialize(skill_store:, skill_catalog:, event_stream:)
|
|
14
19
|
@skill_store = skill_store
|
|
@@ -19,21 +24,26 @@ module Insika
|
|
|
19
24
|
def call(command)
|
|
20
25
|
p = AgentPayload.symbolize(command.payload)
|
|
21
26
|
name = AgentPayload.presence(p[:name])
|
|
27
|
+
agent = AgentPayload.presence(p[:agent])
|
|
22
28
|
content = p[:content].to_s
|
|
23
29
|
raise Insika::ValidationError, "name is required" if name.nil?
|
|
24
30
|
validate_frontmatter!(content)
|
|
25
31
|
|
|
26
|
-
rec = @skill_store.write(name, content, create_only: !!p[:create_only])
|
|
32
|
+
rec = @skill_store.write(name, content, agent: agent, create_only: !!p[:create_only])
|
|
27
33
|
@skill_catalog.reload
|
|
34
|
+
emit(:skill_written, name, agent)
|
|
35
|
+
{ name: name, agent: agent, updated_at: rec["updated_at"] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def emit(type, name, agent)
|
|
28
41
|
@event_stream.emit(Insika::Event.new(
|
|
29
|
-
type:
|
|
42
|
+
type: type, data: { name: name, agent: agent }.compact,
|
|
30
43
|
meta: { at: Time.now.utc.iso8601 }
|
|
31
44
|
))
|
|
32
|
-
{ name: name, updated_at: rec["updated_at"] }
|
|
33
45
|
end
|
|
34
46
|
|
|
35
|
-
private
|
|
36
|
-
|
|
37
47
|
# Mirrors the SkillCatalog parse: without YAML frontmatter with `name`, the skill
|
|
38
48
|
# would be silently ignored on reload — fail early, in the Command.
|
|
39
49
|
def validate_frontmatter!(content)
|
data/lib/insika/config_store.rb
CHANGED
|
@@ -18,15 +18,19 @@ module Insika
|
|
|
18
18
|
# agent_files/skills: the CONTENT of per-agent prompts
|
|
19
19
|
# and shared skills lives in the Store (single source of truth,
|
|
20
20
|
# a SQLite backup), not on disk — disk becomes only seed/import.
|
|
21
|
-
# goldens: authored eval cases
|
|
21
|
+
# goldens: authored eval cases — config, like the
|
|
22
22
|
# prompts and skills: the corpus on disk is seed and export, the store is what a
|
|
23
23
|
# deployment runs and what the Studio edits.
|
|
24
|
-
# baselines: the ACCEPTED state of each agent's golden set
|
|
24
|
+
# baselines: the ACCEPTED state of each agent's golden set.
|
|
25
25
|
# Config for the same reason: it is a curated decision ("this is the bar"), the
|
|
26
26
|
# file is its export, and the refinement gate reads it from inside a deployment
|
|
27
27
|
# that has no checkout.
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
# agent_skills: the per-agent SPECIALIZATIONS of a shared skill (and
|
|
29
|
+
# agent-private skills). A second scope rather than a composite key in `skills`,
|
|
30
|
+
# so the shared records are untouched by the agent dimension arriving — no
|
|
31
|
+
# migration, and a live deployment keeps serving exactly what it served.
|
|
32
|
+
SCOPES = %w[agents settings llm_providers mcp agent_files skills agent_skills
|
|
33
|
+
system_files tools goldens baselines].freeze
|
|
30
34
|
|
|
31
35
|
class UnknownScope < Insika::Error; end
|
|
32
36
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# the durable CONTACT STATE cell per (tenant, customer)— ONE
|
|
7
|
+
# derived cell, never a transition log (D2): `granted | revoked |
|
|
8
|
+
# unavailable`, the sends-without-reply counter and the last outbound
|
|
9
|
+
# timestamp. Dumb domain store: it holds no policy and no follow-up records
|
|
10
|
+
# (the firer and the inbound hook own those transformations).
|
|
11
|
+
#
|
|
12
|
+
# Invariants (the firer enforces them, the store only records):
|
|
13
|
+
# · only `granted` may be messaged; absent = never messaged (:consent block);
|
|
14
|
+
# · `revoked` is immediate and permanent until the customer speaks;
|
|
15
|
+
# · `unavailable` means silence ≠ refusal — set when sends_without_reply
|
|
16
|
+
# reaches the policy's ceiling; further fires block until the customer
|
|
17
|
+
# speaks; any customer message reopens (sets granted + zeroes the counter).
|
|
18
|
+
#
|
|
19
|
+
# The customer identifier is the SAME string the message contract carries
|
|
20
|
+
# (`customer:` on /v1/messages, WS8).
|
|
21
|
+
class ContactStore
|
|
22
|
+
SCOPE = "contacts"
|
|
23
|
+
STATES = %w[granted revoked unavailable].freeze
|
|
24
|
+
|
|
25
|
+
Cell = Data.define(:state, :sends_without_reply, :last_outbound_at, :updated_at)
|
|
26
|
+
|
|
27
|
+
def initialize(store:)
|
|
28
|
+
@store = store
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# -> [Cell] | nil (absent = never messaged — the :consent block). nil
|
|
32
|
+
# customer -> nil (an untagged conversation has no cell).
|
|
33
|
+
def get(tenant:, customer:)
|
|
34
|
+
return nil if customer.to_s.empty?
|
|
35
|
+
|
|
36
|
+
record = @store.get(SCOPE, key(tenant, customer))
|
|
37
|
+
record && to_cell(record)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# -> { "<tenant>:<customer>" => raw record } — the whole scope, for the
|
|
41
|
+
# doctor's contact summary and the Studio (read-only folds; the mutations
|
|
42
|
+
# go through the commands, D10).
|
|
43
|
+
def cells
|
|
44
|
+
@store.list(SCOPE).each_with_object({}) do |k, acc|
|
|
45
|
+
record = @store.get(SCOPE, k)
|
|
46
|
+
acc[k] = record if record
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The ONLY writers. Each is a read-check-write on the cell (#transaction —
|
|
51
|
+
# two processes flipping the state in the same second serialize).
|
|
52
|
+
|
|
53
|
+
# Any customer message reopens the conversation: `granted` + the counter
|
|
54
|
+
# reset. The consent record itself (D2/D7) is the `schedule` tool call.
|
|
55
|
+
def set_granted(tenant:, customer:, now: Time.now.utc)
|
|
56
|
+
write(tenant, customer, now) do |record|
|
|
57
|
+
record["state"] = "granted"
|
|
58
|
+
record["sends_without_reply"] = 0
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# D7: the schedule tool's consent write. The customer agreeing
|
|
63
|
+
# in-conversation IS the consent — but ONLY a customer message reopens
|
|
64
|
+
# (D2): this NEVER lifts `:unavailable` and NEVER resets
|
|
65
|
+
# sends_without_reply, so a re-booking inside the scheduled turn cannot
|
|
66
|
+
# clear the silence protection. Creates the cell when absent (the first
|
|
67
|
+
# consent). Raises Insika::ValidationError on `:revoked` — an opt-out is
|
|
68
|
+
# permanent; the caller refuses.
|
|
69
|
+
def consent(tenant:, customer:, now: Time.now.utc)
|
|
70
|
+
raise Insika::ValidationError, "customer is required" if customer.to_s.empty?
|
|
71
|
+
|
|
72
|
+
@store.transaction do
|
|
73
|
+
record = @store.get(SCOPE, key(tenant, customer)) ||
|
|
74
|
+
{ "state" => "granted", "sends_without_reply" => 0,
|
|
75
|
+
"last_outbound_at" => nil, "updated_at" => nil }
|
|
76
|
+
if record["state"] == "revoked"
|
|
77
|
+
raise Insika::ValidationError,
|
|
78
|
+
"this customer opted out — you cannot schedule a follow-up"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
record["state"] ||= "granted"
|
|
82
|
+
record["updated_at"] = now.iso8601
|
|
83
|
+
@store.set(SCOPE, key(tenant, customer), record)
|
|
84
|
+
to_cell(record)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Keyword / channel opt-out / operator: immediate and permanent until the
|
|
89
|
+
# customer speaks. Nothing auto-revokes.
|
|
90
|
+
def set_revoked(tenant:, customer:, now: Time.now.utc)
|
|
91
|
+
write(tenant, customer, now) do |record|
|
|
92
|
+
record["state"] = "revoked"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Silence reached the policy's ceiling (the firer counts sends without a
|
|
97
|
+
# reply); further fires block until the customer speaks.
|
|
98
|
+
def mark_unavailable(tenant:, customer:, now: Time.now.utc)
|
|
99
|
+
write(tenant, customer, now) do |record|
|
|
100
|
+
record["state"] = "unavailable"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The firer's call: sends_without_reply += 1, last_outbound_at = now.
|
|
105
|
+
# Creates the cell when absent (granted — the firer checks the GO before
|
|
106
|
+
# bumping, so a bump only happens after the consent gate).
|
|
107
|
+
def bump_outbound(tenant:, customer:, now: Time.now.utc)
|
|
108
|
+
write(tenant, customer, now) do |record|
|
|
109
|
+
record["state"] ||= "granted"
|
|
110
|
+
record["sends_without_reply"] = record["sends_without_reply"].to_i + 1
|
|
111
|
+
record["last_outbound_at"] = now.iso8601
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Purge paths (C11 — the LGPD footprint): one cell; a whole tenant's
|
|
116
|
+
# (prefix scan); age-based. All nil-safe.
|
|
117
|
+
|
|
118
|
+
# -> true | false (did the cell exist?)
|
|
119
|
+
def delete(tenant:, customer:)
|
|
120
|
+
return false if customer.to_s.empty?
|
|
121
|
+
|
|
122
|
+
@store.delete(SCOPE, key(tenant, customer))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# -> count removed.
|
|
126
|
+
def purge(tenant:)
|
|
127
|
+
prefix = "#{tenant_id(tenant)}:"
|
|
128
|
+
keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
|
|
129
|
+
keys.each { |k| @store.delete(SCOPE, k) }
|
|
130
|
+
keys.size
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Cells untouched past the cutoff (WS8 retention). -> count removed.
|
|
134
|
+
def delete_older_than(time)
|
|
135
|
+
cutoff = time.utc.iso8601
|
|
136
|
+
removed = 0
|
|
137
|
+
@store.list(SCOPE).each do |k|
|
|
138
|
+
record = @store.get(SCOPE, k)
|
|
139
|
+
next unless record && record["updated_at"].to_s < cutoff
|
|
140
|
+
|
|
141
|
+
@store.delete(SCOPE, k)
|
|
142
|
+
removed += 1
|
|
143
|
+
end
|
|
144
|
+
removed
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# One cell per (tenant, customer); blank tenant -> the literal "platform"
|
|
150
|
+
# (outcome_store.rb's rule — contact, follow-up and outcome keys share one
|
|
151
|
+
# tenant segment so the purge prefix scans line up).
|
|
152
|
+
def key(tenant, customer)
|
|
153
|
+
"#{tenant_id(tenant)}:#{customer}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def tenant_id(tenant)
|
|
157
|
+
t = tenant.to_s
|
|
158
|
+
t.empty? ? "platform" : t
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Read-check-write inside the backend transaction (the budget_ledger.rb:38
|
|
162
|
+
# discipline): two writers racing the same cell serialize on the backend
|
|
163
|
+
# lock and the loser re-reads.
|
|
164
|
+
def write(tenant, customer, now, &block)
|
|
165
|
+
raise Insika::ValidationError, "customer is required" if customer.to_s.empty?
|
|
166
|
+
|
|
167
|
+
@store.transaction do
|
|
168
|
+
record = @store.get(SCOPE, key(tenant, customer)) ||
|
|
169
|
+
{ "state" => "granted", "sends_without_reply" => 0,
|
|
170
|
+
"last_outbound_at" => nil, "updated_at" => nil }
|
|
171
|
+
block.call(record)
|
|
172
|
+
record["updated_at"] = now.iso8601
|
|
173
|
+
@store.set(SCOPE, key(tenant, customer), record)
|
|
174
|
+
to_cell(record)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def to_cell(record)
|
|
179
|
+
Cell.new(state: record["state"], sends_without_reply: record["sends_without_reply"].to_i,
|
|
180
|
+
last_outbound_at: record["last_outbound_at"], updated_at: record["updated_at"])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -19,7 +19,7 @@ module Insika
|
|
|
19
19
|
def initialize(providers:, event_stream:, hooks: Hooks.new, estimator: TokenEstimator)
|
|
20
20
|
@providers = providers
|
|
21
21
|
@event_stream = event_stream
|
|
22
|
-
@hooks = hooks #
|
|
22
|
+
@hooks = hooks # prompt pair; empty Hooks = no-op
|
|
23
23
|
@estimator = estimator
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -66,7 +66,14 @@ module Insika
|
|
|
66
66
|
|
|
67
67
|
fragments = []
|
|
68
68
|
tasks.each do |provider, child|
|
|
69
|
-
|
|
69
|
+
# stamp the layer at PRODUCTION from the PROVIDER's
|
|
70
|
+
# declaration — authoritative, so a fragment that arrives pre-stamped
|
|
71
|
+
# cannot sneak above the boundary (a :volatile provider emitting a
|
|
72
|
+
# fragment with layer :identity would otherwise bypass the doctor's
|
|
73
|
+
# class-level check). A custom provider that never learned the contract
|
|
74
|
+
# leaves :volatile.
|
|
75
|
+
layer = provider.respond_to?(:layer) ? provider.layer : :volatile
|
|
76
|
+
Array(child.wait).each { |f| fragments << f.with(layer: layer) }
|
|
70
77
|
rescue StandardError => e # Async::TimeoutError is a StandardError; Async::Stop is NOT (propagates)
|
|
71
78
|
handle_provider_failure(provider, e, request)
|
|
72
79
|
end
|
|
@@ -97,7 +104,7 @@ module Insika
|
|
|
97
104
|
def estimable_text(content)
|
|
98
105
|
case content
|
|
99
106
|
when String then content
|
|
100
|
-
# An eviction unit (
|
|
107
|
+
# An eviction unit (R1): a cycle of message Hashes -> sum their text.
|
|
101
108
|
when Array then content.map { |c| estimable_text(c) }.join(" ")
|
|
102
109
|
when Hash then content.values.map(&:to_s).join(" ")
|
|
103
110
|
else content.to_s
|
|
@@ -135,10 +142,14 @@ module Insika
|
|
|
135
142
|
[survivors, evicted_sources]
|
|
136
143
|
end
|
|
137
144
|
|
|
138
|
-
# Step 6: assembly in DETERMINISTIC canonical order.
|
|
145
|
+
# Step 6: assembly in DETERMINISTIC canonical order. The
|
|
146
|
+
# identity-first partition — nothing volatile renders above the cache
|
|
147
|
+
# boundary. Each partition keeps the existing canonical sort; the partition
|
|
148
|
+
# is by LAYER only.
|
|
139
149
|
def assemble(fragments, cap, evicted)
|
|
140
150
|
system_frags = fragments.select { |f| f.placement == :system }
|
|
141
|
-
|
|
151
|
+
identity, volatile = system_frags.partition { |f| (f.layer || :volatile) == :identity }
|
|
152
|
+
system_frags = sort_canonical(identity) + sort_canonical(volatile)
|
|
142
153
|
history_frags = fragments.select { |f| f.placement == :history } # production order (chronological)
|
|
143
154
|
tool_frags = fragments.select { |f| f.placement == :tool_context }
|
|
144
155
|
|
|
@@ -153,6 +164,13 @@ module Insika
|
|
|
153
164
|
)
|
|
154
165
|
end
|
|
155
166
|
|
|
167
|
+
# The canonical system sort (priority DESC, source ASC, production index) —
|
|
168
|
+
# applied WITHIN each layer partition, so the prior order survives inside
|
|
169
|
+
# it and the boundary is the only thing that moved.
|
|
170
|
+
def sort_canonical(frags)
|
|
171
|
+
frags.sort_by.with_index { |f, i| [-f.priority, f.source.to_s, i] }
|
|
172
|
+
end
|
|
173
|
+
|
|
156
174
|
# :provider_warning. The Builder does not know task_id/seq (correlation is
|
|
157
175
|
# the Executor's job) — emits with what it has; Event#to_h does meta.compact.
|
|
158
176
|
def emit_warning(provider_id, message, request)
|
|
@@ -8,12 +8,40 @@ module Insika
|
|
|
8
8
|
# tokens: Integer | nil; estimated by the Builder when nil
|
|
9
9
|
# source: String — provider id (audit)
|
|
10
10
|
# pinned: true -> uncuttable in the budget (e.g. identity)
|
|
11
|
+
# layer: :identity | :volatile | nil. Stamped by the Builder
|
|
12
|
+
# at production from the provider's declaration; nil (a fragment
|
|
13
|
+
# built outside the Builder) reads as :volatile everywhere it is
|
|
14
|
+
# consumed — parity, never a crash.
|
|
15
|
+
# labels: [{ "name" =>, "reason" => }] — WHAT this fragment carries and WHY,
|
|
16
|
+
# as ids. Content-FREE by contract, so the context trace can report
|
|
17
|
+
# which skills a turn injected without storing a byte of the bodies.
|
|
18
|
+
# [] = nothing to name (the default for every provider that has no
|
|
19
|
+
# natural id, e.g. the identity prompt).
|
|
20
|
+
#
|
|
21
|
+
# The REASON is the point. A name alone answers "was something
|
|
22
|
+
# injected"; the operator's actual question is "which skill did I
|
|
23
|
+
# trigger, and why is it here" — `eager` (the agent always wants it),
|
|
24
|
+
# `trigger:<matched phrase>` (this message asked for it), or absent
|
|
25
|
+
# for a body a plugin supplied. String keys because these labels are
|
|
26
|
+
# written to the context trace and to events as JSON: the round-trip
|
|
27
|
+
# is then the identity, and no reader has to defend against both.
|
|
11
28
|
ContextFragment = Data.define(:content, :placement, :priority, :tokens,
|
|
12
|
-
:source, :pinned) do
|
|
29
|
+
:source, :pinned, :labels, :layer) do
|
|
13
30
|
def self.build(content:, placement:, source:, priority: 50, tokens: nil,
|
|
14
|
-
pinned: false)
|
|
31
|
+
pinned: false, labels: [], layer: nil)
|
|
15
32
|
new(content: content, placement: placement, priority: priority,
|
|
16
|
-
tokens: tokens, source: source, pinned: pinned
|
|
33
|
+
tokens: tokens, source: source, pinned: pinned,
|
|
34
|
+
labels: Array(labels).map { |l| label(l) }, layer: layer)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A bare String is still a valid label (a provider that has an id but no reason
|
|
38
|
+
# to give) — it normalizes to a reason-less entry rather than being rejected.
|
|
39
|
+
def self.label(raw)
|
|
40
|
+
return { "name" => raw.to_s }.freeze unless raw.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
name = (raw[:name] || raw["name"]).to_s
|
|
43
|
+
reason = raw[:reason] || raw["reason"]
|
|
44
|
+
{ "name" => name, "reason" => reason&.to_s }.compact.freeze
|
|
17
45
|
end
|
|
18
46
|
end
|
|
19
47
|
end
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
module Insika
|
|
4
4
|
module Context
|
|
5
5
|
# Precedence ladder for context fragments — SINGLE SOURCE of the order
|
|
6
|
-
# (
|
|
6
|
+
# (trust boundary). Higher = more authority: appears earlier
|
|
7
7
|
# in the system prompt and survives budget cuts. Providers reference these
|
|
8
8
|
# constants instead of loose numbers, so the boundary is an auditable
|
|
9
9
|
# contract in a single place (trust_boundary_spec locks the order).
|
|
10
10
|
#
|
|
11
|
-
# Contract
|
|
11
|
+
# Contract: identity and guardrails go PINNED at the top; TURN
|
|
12
12
|
# injections (request_context — the consumer's tenant/vars) sit at the
|
|
13
13
|
# BOTTOM and are sacrificed FIRST under budget. Identity (pinned) is NEVER
|
|
14
14
|
# truncated. A prompt injection riding in via turn data is DATA, not
|
|
@@ -18,9 +18,13 @@ module Insika
|
|
|
18
18
|
module Priority
|
|
19
19
|
IDENTITY = 100 # IDENTITY/SOUL (Prompt) — pinned
|
|
20
20
|
PROMPT_REF = 90 # Prompt Catalog guardrails/refs (Prompt) — pinned
|
|
21
|
+
SKILL_BODY = 85 # <active_skill> trigger-matched body (SkillTrigger)
|
|
21
22
|
SKILL = 80 # <available_skills> level 1 (Skill)
|
|
22
23
|
MEMORY = 75 # <memory> read path (Memory)
|
|
23
24
|
TOOL_SEARCH = 70 # <available_tools> level 1 (ToolSearch)
|
|
25
|
+
BRIEFING = 65 # <briefing> session working state (Briefing) — D5:
|
|
26
|
+
# below every identity/skill/memory block (never breaks the
|
|
27
|
+
# pinned prefix), above the turn's own <request_context>.
|
|
24
28
|
HISTORY_MAX = 79 # history ceiling by recency (Session)
|
|
25
29
|
HISTORY_BASE = 60 # history base; +idx up to the ceiling (Session)
|
|
26
30
|
REQUEST = 40 # <request_context> — turn injection, the most cuttable
|
|
@@ -9,11 +9,25 @@ module Insika
|
|
|
9
9
|
def required? = false # true -> failure aborts the turn
|
|
10
10
|
def enabled_for?(_profile) = true
|
|
11
11
|
def call(_request) = [] # -> [ContextFragment]; may do IO
|
|
12
|
+
# which cache layer the output belongs to.
|
|
13
|
+
# :identity -> changes only on deploy/config edit (the cacheable prefix);
|
|
14
|
+
# :volatile -> may change per turn (the Builder renders identity first).
|
|
15
|
+
# :volatile is the conservative default — nothing gets pinned by accident.
|
|
16
|
+
def layer = :volatile
|
|
12
17
|
end
|
|
13
18
|
|
|
14
19
|
# Input for the provider contract.
|
|
15
|
-
# session:
|
|
16
|
-
# checkpoint:
|
|
20
|
+
# session: SessionStore::Session | nil
|
|
21
|
+
# checkpoint: Checkpoint | nil (present on ResumeTask — history comes from it)
|
|
22
|
+
# memory_scope: the CUSTOMER-scoped memory cell (WS8): "[tenant:]customer"
|
|
23
|
+
# when the request carries a customer, else nil (the providers
|
|
24
|
+
# fall back to tenant || session). Kept separate from `tenant`
|
|
25
|
+
# (the <request_context> merchant label) on purpose.
|
|
17
26
|
ContextRequest = Data.define(:session, :message, :profile, :tenant, :vars,
|
|
18
|
-
:checkpoint)
|
|
27
|
+
:checkpoint, :memory_scope) do
|
|
28
|
+
def initialize(session: nil, message: nil, profile: nil, tenant: nil, vars: {},
|
|
29
|
+
checkpoint: nil, memory_scope: nil)
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
19
33
|
end
|