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,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Read path for the session briefing: the per-session
|
|
7
|
+
# working-state the agent keeps and asks for. Thin adapter over the
|
|
8
|
+
# SessionStore, same pattern as Memory: one `:system` fragment,
|
|
9
|
+
# deterministic. The MISSING list is rendered, never implied — that list
|
|
10
|
+
# is what stops the model re-asking for a field already given.
|
|
11
|
+
class Briefing < ContextProvider
|
|
12
|
+
def initialize(session_store:)
|
|
13
|
+
@session_store = session_store
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Stable id -> the context-trace category "briefing".
|
|
17
|
+
def id = "briefing"
|
|
18
|
+
|
|
19
|
+
# Pack-declared: no briefing_fields -> no provider (the Builder still
|
|
20
|
+
# applies the `context_providers` allowlist on top — two gates, like Memory).
|
|
21
|
+
def enabled_for?(profile)
|
|
22
|
+
fields = profile.respond_to?(:briefing_fields) ? profile.briefing_fields : nil
|
|
23
|
+
!Array(fields).empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# required? == false (default): a store failure degrades via the Builder's
|
|
27
|
+
# warning path, never aborts the turn.
|
|
28
|
+
def call(request)
|
|
29
|
+
session = request.respond_to?(:session) ? request.session : nil
|
|
30
|
+
return [] if session.nil? # one-shot turns have no briefing
|
|
31
|
+
|
|
32
|
+
briefing = briefing_for(session)
|
|
33
|
+
fields = briefing["fields"] || {}
|
|
34
|
+
declared = Array(request.profile.briefing_fields)
|
|
35
|
+
return [] if declared.empty? # defensive; enabled_for? already gates
|
|
36
|
+
|
|
37
|
+
block = format_block(declared, fields, briefing["next_step"])
|
|
38
|
+
return [] if block.nil?
|
|
39
|
+
|
|
40
|
+
[ContextFragment.build(content: block, placement: :system,
|
|
41
|
+
priority: Context::Priority::BRIEFING,
|
|
42
|
+
source: id)]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Re-reads the briefing from the store, like the Session provider: the
|
|
48
|
+
# persisted record is the source of truth, not the request's turn-start
|
|
49
|
+
# snapshot. A read failure propagates to the Builder, which degrades it
|
|
50
|
+
# to a :provider_warning (required? == false).
|
|
51
|
+
def briefing_for(session)
|
|
52
|
+
@session_store.find(session.id)&.briefing || {}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Byte contract (the specs assert this shape):
|
|
56
|
+
# <briefing>
|
|
57
|
+
# known:
|
|
58
|
+
# size: M
|
|
59
|
+
# still missing: delivery_day
|
|
60
|
+
# next step: send the payment link tomorrow at 10
|
|
61
|
+
# </briefing>
|
|
62
|
+
# Rules: `known` renders only when at least one declared field has a
|
|
63
|
+
# stored value; `still missing` renders every declared field with no
|
|
64
|
+
# stored value (including the all-missing case — that is the block's
|
|
65
|
+
# job); `next step` renders only when non-nil; stored keys NOT in the
|
|
66
|
+
# declaration are never rendered (they stay in the store and reappear if
|
|
67
|
+
# the pack re-declares them).
|
|
68
|
+
def format_block(declared, fields, next_step)
|
|
69
|
+
known = declared.filter_map do |name|
|
|
70
|
+
" #{name}: #{flatten(fields[name])}" if Coercion.present?(fields[name])
|
|
71
|
+
end
|
|
72
|
+
missing = declared.reject { |name| Coercion.present?(fields[name]) }
|
|
73
|
+
|
|
74
|
+
lines = []
|
|
75
|
+
lines << "known:" unless known.empty?
|
|
76
|
+
lines.concat(known)
|
|
77
|
+
lines << "still missing: #{missing.join(', ')}" unless missing.empty?
|
|
78
|
+
lines << "next step: #{flatten(next_step)}" if Coercion.present?(next_step)
|
|
79
|
+
return nil if lines.empty?
|
|
80
|
+
|
|
81
|
+
<<~BLOCK.strip
|
|
82
|
+
<briefing>
|
|
83
|
+
#{lines.join("\n")}
|
|
84
|
+
</briefing>
|
|
85
|
+
BLOCK
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# utf8 the value and flatten newlines/whitespace so a value can never
|
|
89
|
+
# break the block's line structure.
|
|
90
|
+
def flatten(value)
|
|
91
|
+
Coercion.utf8(value.to_s).gsub(/\s+/, " ").strip
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -20,7 +20,7 @@ module Insika
|
|
|
20
20
|
# required? == false (default): a failure (store unavailable) becomes a
|
|
21
21
|
# :provider_warning + graceful degradation — never aborts the turn.
|
|
22
22
|
def call(request)
|
|
23
|
-
tenant =
|
|
23
|
+
tenant = memory_scope(request)
|
|
24
24
|
facts = @store.facts(tenant: tenant)
|
|
25
25
|
notes = @store.notes(tenant: tenant, limit: @notes_limit)
|
|
26
26
|
return [] if facts.empty? && notes.empty?
|
|
@@ -33,15 +33,24 @@ module Insika
|
|
|
33
33
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
|
-
# Engine memory scope (
|
|
37
|
-
# (
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
36
|
+
# Engine memory scope (WS8): the request's CUSTOMER-scoped cell
|
|
37
|
+
# ("[tenant:]customer" — engine-owner memory is per customer, never per
|
|
38
|
+
# tenant) wins; otherwise an EXPLICIT tenant from the Command (the
|
|
39
|
+
# multi-merchant override); otherwise the SESSION (=chat), MARKED like
|
|
40
|
+
# the write path ("chat:<session id>" — : a session cell is
|
|
41
|
+
# never a bare cell, so the drill cannot read a conversation as a
|
|
42
|
+
# customer). No session (one-shot) and no tenant -> nil (MemoryStore
|
|
43
|
+
# applies _default). Symmetric to the write path (`state.tenant` in the
|
|
44
|
+
# Executor).
|
|
45
|
+
def memory_scope(request)
|
|
46
|
+
scoped = request.respond_to?(:memory_scope) ? request.memory_scope : nil
|
|
47
|
+
return scoped if scoped
|
|
48
|
+
|
|
41
49
|
explicit = request.respond_to?(:tenant) ? request.tenant : nil
|
|
42
50
|
return explicit if explicit
|
|
43
51
|
|
|
44
|
-
request.respond_to?(:session) ? request.session
|
|
52
|
+
session = request.respond_to?(:session) ? request.session : nil
|
|
53
|
+
session && session.id ? "#{Insika::MemoryStore::SESSION_TAG}:#{session.id}" : nil
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
# Passive <memory> (no instruction — the HOW of writing lives in the `remember` tool).
|
|
@@ -10,11 +10,26 @@ module Insika
|
|
|
10
10
|
#
|
|
11
11
|
# PER-AGENT identity. `profile.prompt_files` (file names)
|
|
12
12
|
# wins over the wiring's `files:` — this fixes the limitation of a new
|
|
13
|
-
# agent inheriting
|
|
13
|
+
# agent inheriting the previous persona's prompt. The content comes from `agent_files`
|
|
14
14
|
# (AgentFileStore, lives in the Store), with a File.read fallback
|
|
15
15
|
# for on-disk paths (compat/seed). Without prompt_files -> uses the wiring's
|
|
16
16
|
# `files:` (deployment default; byte-for-byte parity).
|
|
17
17
|
class Prompt < ContextProvider
|
|
18
|
+
# Engine-owned execution discipline, appended AFTER the agent's identity.
|
|
19
|
+
# The one behavior every reference harness bakes into its base prompt
|
|
20
|
+
# (OpenClaw's "Execution Bias") and this engine was missing: a weak tool
|
|
21
|
+
# result read as final. A constant — byte-identical every turn, so
|
|
22
|
+
# prompt_caching pays ONE write on the deploy that introduces it, never
|
|
23
|
+
# per turn. Opt-out per profile (`tool_persistence false`), the single
|
|
24
|
+
# default-ON profile flag: the proven-good behavior is the default, the
|
|
25
|
+
# exception is the thing an operator declares.
|
|
26
|
+
TOOL_PERSISTENCE = "## Tool discipline\n" \
|
|
27
|
+
"- Weak or empty tool result: try again with a different approach — rephrase the " \
|
|
28
|
+
"query, use a synonym or broader term, drop a secondary filter — before telling " \
|
|
29
|
+
"the user you found nothing. Do not narrate the retries. Then conclude.\n" \
|
|
30
|
+
"- Tool error: read the error, fix the arguments or try another path; never " \
|
|
31
|
+
"repeat the exact same call."
|
|
32
|
+
|
|
18
33
|
def initialize(base: "", files: [], catalog: nil, agent_files: nil, system_files: nil)
|
|
19
34
|
@base = base
|
|
20
35
|
@files = Array(files)
|
|
@@ -24,6 +39,8 @@ module Insika
|
|
|
24
39
|
end
|
|
25
40
|
|
|
26
41
|
def required? = true
|
|
42
|
+
# identity (config/agent-file derived — already pinned).
|
|
43
|
+
def layer = :identity
|
|
27
44
|
|
|
28
45
|
def call(request)
|
|
29
46
|
fragments = []
|
|
@@ -62,7 +79,18 @@ module Insika
|
|
|
62
79
|
else
|
|
63
80
|
sources.each { |src| parts << read_source(profile&.id, src.to_s) }
|
|
64
81
|
end
|
|
65
|
-
parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
|
|
82
|
+
identity = parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
|
|
83
|
+
# Discipline rides an EXISTING identity, never substitutes one: an
|
|
84
|
+
# agent with no identity at all must stay detectably empty.
|
|
85
|
+
return identity if identity.empty? || !tool_persistence?(profile)
|
|
86
|
+
|
|
87
|
+
"#{identity}\n\n#{TOOL_PERSISTENCE}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# nil/absent/true = ON (the engine default); only an explicit `false`
|
|
91
|
+
# turns it off. Defensive respond_to?: a minimal profile stub reads ON.
|
|
92
|
+
def tool_persistence?(profile)
|
|
93
|
+
!(profile.respond_to?(:tool_persistence) && profile.tool_persistence == false)
|
|
66
94
|
end
|
|
67
95
|
|
|
68
96
|
# GLOBAL system files: apply to every agent,
|
|
@@ -13,7 +13,7 @@ module Insika
|
|
|
13
13
|
# "history" is transcript (consumed by the Session provider), not turn
|
|
14
14
|
# metadata — it does not leak into the system's request_context. Keys
|
|
15
15
|
# prefixed with "__" are INTERNAL slots (e.g. the per-chat model pin
|
|
16
|
-
# "__llm__"
|
|
16
|
+
# "__llm__") — reserved, never rendered to the model.
|
|
17
17
|
request.vars.to_h.each do |k, v|
|
|
18
18
|
next if k.to_s == "history" || k.to_s.start_with?("__")
|
|
19
19
|
|
|
@@ -17,7 +17,13 @@ module Insika
|
|
|
17
17
|
messages = transcript_for(request)
|
|
18
18
|
return [] if messages.nil? || messages.empty?
|
|
19
19
|
|
|
20
|
-
#
|
|
20
|
+
# A3/C3 opt-in: identical tool results in the transcript collapse to a
|
|
21
|
+
# back-reference (the cheap half of compaction). CHANGES WHAT THE MODEL
|
|
22
|
+
# SEES — hence the profile flag, never a default. Applied BEFORE the
|
|
23
|
+
# eviction-unit grouping so a cycle's results are already slim.
|
|
24
|
+
messages = compress_history(messages, request)
|
|
25
|
+
|
|
26
|
+
# 1 fragment per EVICTION UNIT (R1): a plain message, OR an
|
|
21
27
|
# assistant-with-tool_calls together with its tool results. Grouping at
|
|
22
28
|
# the fragment level means the budget cut (apply_budget) drops a whole
|
|
23
29
|
# tool cycle atomically — a tool_use is NEVER seeded without its result
|
|
@@ -72,7 +78,16 @@ module Insika
|
|
|
72
78
|
h
|
|
73
79
|
end
|
|
74
80
|
|
|
75
|
-
|
|
81
|
+
def tool_calls?(msg) = msg[:tool_calls] && !Array(msg[:tool_calls]).empty?
|
|
82
|
+
|
|
83
|
+
# The compression is opt-in per agent (profile data, config-over-code):
|
|
84
|
+
# absent/off -> the transcript passes through byte-identical (parity).
|
|
85
|
+
def compress_history(messages, request)
|
|
86
|
+
profile = request.respond_to?(:profile) ? request.profile : nil
|
|
87
|
+
return messages unless profile&.tool_output_compression
|
|
88
|
+
|
|
89
|
+
ToolOutputCompressor.compress_transcript(messages)
|
|
90
|
+
end
|
|
76
91
|
|
|
77
92
|
# Precedence: checkpoint -> explicit history -> store.
|
|
78
93
|
# The first present source wins; no merge.
|
|
@@ -10,10 +10,16 @@ module Insika
|
|
|
10
10
|
class Skill < CatalogProvider
|
|
11
11
|
# priority 80: above deferred tools (70), below pinned identity.
|
|
12
12
|
def priority = Context::Priority::SKILL
|
|
13
|
+
# catalog + allowlist — config only.
|
|
14
|
+
def layer = :identity
|
|
13
15
|
|
|
14
16
|
private
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
# Only the skills the model still has to ASK for. An eager skill is already in
|
|
19
|
+
# the prompt in full, so advertising it here would invite a `load_skill` call
|
|
20
|
+
# that buys a duplicate — and the catalog's whole job is to describe what is
|
|
21
|
+
# NOT yet loaded. Nothing lazy left -> CatalogProvider emits no fragment.
|
|
22
|
+
def entries(request) = @catalog.lazy_for(request.profile)
|
|
17
23
|
end
|
|
18
24
|
end
|
|
19
25
|
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Level-2 skill BODIES in the prompt, with two selection modes — both
|
|
7
|
+
# deterministic, neither asks the model:
|
|
8
|
+
#
|
|
9
|
+
# profile.skills_eager -> the agent's eager set (all, or a named list),
|
|
10
|
+
# every turn. No decision at all, so no miss rate.
|
|
11
|
+
# Costs the bodies' tokens.
|
|
12
|
+
# `triggers:` in the -> only the skills whose trigger matches this
|
|
13
|
+
# frontmatter message (whole word, accent- and case-insensitive).
|
|
14
|
+
#
|
|
15
|
+
# Model-invoked loading (load_skill) stays the fallback for everything else.
|
|
16
|
+
#
|
|
17
|
+
# A trigger only belongs on a skill that can COMPLETE the turn by itself.
|
|
18
|
+
# Injecting a reference table whose companion skill holds the procedure is
|
|
19
|
+
# worse than injecting nothing: the model has a plausible half-recipe in the
|
|
20
|
+
# prompt, so it never calls load_skill for the other half. Measured on a real
|
|
21
|
+
# pack — the line map arrived, the query-construction rules did not, and the
|
|
22
|
+
# searches came out malformed.
|
|
23
|
+
# Activation here is NOT a tool call, so nothing in the transcript would show
|
|
24
|
+
# it. The fragment's `labels` carry the name AND THE REASON, and the EXECUTOR
|
|
25
|
+
# announces them (:skill_activated) — not this provider. A provider only has the
|
|
26
|
+
# ContextRequest, which has no task, and the Studio's SSE drops an event whose
|
|
27
|
+
# meta lacks `task_id` when the subscriber is task-scoped (Subscription#matches?):
|
|
28
|
+
# emitting from here produced an event that was correct and never arrived.
|
|
29
|
+
class SkillTrigger < ContextProvider
|
|
30
|
+
def initialize(catalog:)
|
|
31
|
+
@catalog = catalog
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(request)
|
|
35
|
+
matched = select(request)
|
|
36
|
+
return [] if matched.empty?
|
|
37
|
+
|
|
38
|
+
content = matched.map do |skill, _reason|
|
|
39
|
+
%(<active_skill name="#{skill.name}">\n#{skill.body}\n</active_skill>)
|
|
40
|
+
end.join("\n\n")
|
|
41
|
+
|
|
42
|
+
labels = matched.map { |skill, reason| { "name" => skill.name, "reason" => reason } }
|
|
43
|
+
[ContextFragment.build(content: content, placement: :system,
|
|
44
|
+
priority: Context::Priority::SKILL_BODY, source: id,
|
|
45
|
+
labels: labels)]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# -> [[skill, reason]]. Two independent reasons a body lands here: the AGENT
|
|
51
|
+
# marked it eager (always), or a `triggers:` entry matched THIS message.
|
|
52
|
+
# The union is injected; everything else stays at level 1 for load_skill, where
|
|
53
|
+
# the model's call is the only record of what it reached for.
|
|
54
|
+
#
|
|
55
|
+
# The reason travels with the skill from here to the activation card, because
|
|
56
|
+
# "which skills were active" without "why each one was" is the information the
|
|
57
|
+
# deterministic paths destroyed when they replaced the load_skill call.
|
|
58
|
+
def select(request)
|
|
59
|
+
eager = @catalog.eager_for(request.profile).map { |skill| [skill, "eager"] }
|
|
60
|
+
# `triggered` reads the LAZY set, which excludes the eager one — so a skill
|
|
61
|
+
# cannot arrive twice. uniq_by name anyway: the invariant is worth not
|
|
62
|
+
# depending on from here.
|
|
63
|
+
selected = (eager + triggered(request)).uniq { |skill, _reason| skill.name }
|
|
64
|
+
(selected + companions(selected, request)).uniq { |skill, _reason| skill.name }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Declared `companions:` travel with whatever brought them, so the half-recipe
|
|
68
|
+
# state cannot be assembled: the line map that arrived by trigger takes its
|
|
69
|
+
# query-construction rules with it. ONE LEVEL, deliberately — a transitive walk
|
|
70
|
+
# would make a cycle a hang and a chain a budget blowout, and "cannot work
|
|
71
|
+
# without" is a direct relationship.
|
|
72
|
+
#
|
|
73
|
+
# Restricted to the agent's own allowed set: a companion the agent cannot see
|
|
74
|
+
# is not injectable, and `doctor` flags that declaration rather than the engine
|
|
75
|
+
# quietly widening the allowlist.
|
|
76
|
+
def companions(selected, request)
|
|
77
|
+
wanted = selected.flat_map { |skill, _reason| Array(skill.companions).map { |c| [c.to_s, skill.name] } }
|
|
78
|
+
return [] if wanted.empty?
|
|
79
|
+
|
|
80
|
+
by_name = @catalog.effective(request.profile.skills, agent: request.profile.id)
|
|
81
|
+
.each_with_object({}) { |s, acc| acc[s.name] = s }
|
|
82
|
+
wanted.filter_map do |name, of|
|
|
83
|
+
skill = by_name[name]
|
|
84
|
+
[skill, "companion:#{of}"] if skill
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def triggered(request)
|
|
89
|
+
message = fold(request.message)
|
|
90
|
+
return [] if message.empty?
|
|
91
|
+
|
|
92
|
+
@catalog.lazy_for(request.profile).filter_map do |skill|
|
|
93
|
+
phrase = matched_trigger(skill, message)
|
|
94
|
+
[skill, "trigger:#{phrase}"] if phrase
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The trigger phrase that fired, or nil. AS AUTHORED, never as typed: the
|
|
99
|
+
# reason lands on the activation card, and what an operator needs there is the
|
|
100
|
+
# config line they can go and edit — not an echo of the customer's message
|
|
101
|
+
# (which also keeps the label content-free, like the rest of the trace).
|
|
102
|
+
#
|
|
103
|
+
# Two hygiene rules, and both are tokenization rather than the semantic
|
|
104
|
+
# matching this feature deliberately does not do:
|
|
105
|
+
#
|
|
106
|
+
# whole word — bare substring made `triggers: presente` fire inside
|
|
107
|
+
# *apresente*, and the card now PRINTS the matched phrase: `trigger:presente`
|
|
108
|
+
# on a turn about *apresentação* would discredit the card on day one.
|
|
109
|
+
#
|
|
110
|
+
# folded accents — the corpus is Portuguese and customers type *maquiagem*
|
|
111
|
+
# and *maquiágem* unpredictably, so both sides are folded before comparing.
|
|
112
|
+
def matched_trigger(skill, folded_message)
|
|
113
|
+
skill.triggers.find do |trigger|
|
|
114
|
+
needle = fold(trigger)
|
|
115
|
+
next false if needle.empty?
|
|
116
|
+
|
|
117
|
+
/(?<![[:alnum:]])#{Regexp.escape(needle)}(?![[:alnum:]])/.match?(folded_message)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# NFD splits an accented letter into letter + combining mark; dropping the
|
|
122
|
+
# marks (\p{Mn}) leaves the bare letter, so "maquiágem" and "maquiagem" fold
|
|
123
|
+
# to the same string.
|
|
124
|
+
def fold(text) = text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Per-session CONTEXT trace, for the Studio's breakdown-by-category card
|
|
5
|
+
# One record per session in the raw backend (scope
|
|
6
|
+
# "context_traces") — RUNTIME data, next to sessions/tasks, like the
|
|
7
|
+
# ToolTraceStore it mirrors. A capped LIST of entries, ONE PER TURN, written
|
|
8
|
+
# by the Executor after tool assembly.
|
|
9
|
+
#
|
|
10
|
+
# Unlike the tool trace there is NO security machinery here by construction:
|
|
11
|
+
# the entry is counts and provider ids only (category -> tokens/fragments/
|
|
12
|
+
# pinned, the tools estimate, the budget verdict) — never fragment content,
|
|
13
|
+
# so there is nothing to mask. `record` still rescues everything: the trace
|
|
14
|
+
# NEVER breaks the turn.
|
|
15
|
+
class ContextTraceStore
|
|
16
|
+
SCOPE = "context_traces"
|
|
17
|
+
MAX_PER_SESSION = 50 # one per turn; turns are the unit, not tool calls
|
|
18
|
+
|
|
19
|
+
def initialize(store:)
|
|
20
|
+
@store = store
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Writes an entry into the session — UPSERT by (task_id, turn): a turn that
|
|
24
|
+
# suspends (approval) and resumes re-runs the context stage, and the
|
|
25
|
+
# re-record replaces the first one instead of duplicating it. (`turn` is
|
|
26
|
+
# 1-based PER TASK, so the task is part of the key.) Missing session_id ->
|
|
27
|
+
# no-op. -> the sanitized entry (parks it on TurnState for the
|
|
28
|
+
# stage-8 cache merge).
|
|
29
|
+
def record(session_id:, entry:)
|
|
30
|
+
sid = session_id.to_s
|
|
31
|
+
return if sid.empty?
|
|
32
|
+
|
|
33
|
+
e = sanitize(entry)
|
|
34
|
+
key = [e["task_id"], e["turn"]]
|
|
35
|
+
list = (@store.get(SCOPE, sid) || []).reject { |x| [x["task_id"], x["turn"]] == key } + [e]
|
|
36
|
+
@store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
|
|
37
|
+
e
|
|
38
|
+
rescue StandardError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# -> [Hash] session entries in chronological order. [] if none.
|
|
43
|
+
def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
|
|
44
|
+
|
|
45
|
+
# Discards a session's trace (cleanup). -> bool (did it exist?).
|
|
46
|
+
def clear(session_id) = @store.delete(SCOPE, session_id.to_s)
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Keeps only the known shape; coerces numbers and strings so a caller bug
|
|
51
|
+
# degrades the card instead of poisoning the record.
|
|
52
|
+
def sanitize(entry)
|
|
53
|
+
e = entry.is_a?(Hash) ? entry : {}
|
|
54
|
+
categories = e[:categories] || e["categories"] || {}
|
|
55
|
+
tools = e[:tools] || e["tools"] || {}
|
|
56
|
+
{
|
|
57
|
+
"task_id" => (e[:task_id] || e["task_id"]).to_s,
|
|
58
|
+
"turn" => int(e[:turn] || e["turn"]),
|
|
59
|
+
"at" => (e[:at] || e["at"]).to_s,
|
|
60
|
+
"cap" => int(e[:cap] || e["cap"]),
|
|
61
|
+
"used" => int(e[:used] || e["used"]),
|
|
62
|
+
"evicted" => Array(e[:evicted] || e["evicted"]).map(&:to_s),
|
|
63
|
+
"categories" => categories.each_with_object({}) do |(name, c), acc|
|
|
64
|
+
c = {} unless c.is_a?(Hash)
|
|
65
|
+
cat = { "tokens" => int(c[:tokens] || c["tokens"]),
|
|
66
|
+
"fragments" => int(c[:fragments] || c["fragments"]),
|
|
67
|
+
"pinned" => int(c[:pinned] || c["pinned"]) }
|
|
68
|
+
# which cache layer the category belongs to ("identity" |
|
|
69
|
+
# "volatile"). Absent for a category recorded before the contract (or
|
|
70
|
+
# one that never learned it) — the view guards on nil.
|
|
71
|
+
layer = c[:layer] || c["layer"]
|
|
72
|
+
cat["layer"] = layer.to_s if layer
|
|
73
|
+
# WHAT the category carried and WHY ({name, reason}) — still ids only, so
|
|
74
|
+
# the no-masking-needed contract above holds. Omitted when empty: most
|
|
75
|
+
# categories have nothing to name and an empty key is just noise.
|
|
76
|
+
labels = normalize_labels(c[:labels] || c["labels"])
|
|
77
|
+
cat["labels"] = labels unless labels.empty?
|
|
78
|
+
acc[name.to_s] = cat
|
|
79
|
+
end,
|
|
80
|
+
"tools" => { "count" => int(tools[:count] || tools["count"]),
|
|
81
|
+
"tokens" => int(tools[:tokens] || tools["tokens"]) },
|
|
82
|
+
"fingerprints" => fingerprints_of(e[:fingerprints] || e["fingerprints"]),
|
|
83
|
+
"cache" => cache_of(e[:cache] || e["cache"])
|
|
84
|
+
}.compact
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# { name => sha256-hex }; names stringified, non-strings
|
|
88
|
+
# dropped. Absent when the caller passed nothing (a trace recorded before
|
|
89
|
+
# this feature has no key and the view guards on nil).
|
|
90
|
+
def fingerprints_of(raw)
|
|
91
|
+
return nil unless raw.is_a?(Hash) && !raw.empty?
|
|
92
|
+
|
|
93
|
+
raw.each_with_object({}) do |(name, hex), acc|
|
|
94
|
+
acc[name.to_s] = hex.to_s if hex.is_a?(String)
|
|
95
|
+
end.then { |h| h.empty? ? nil : h }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# { hit_pct, cached_tokens, prompt_tokens, invalidation_reason }.
|
|
99
|
+
# Unknown keys dropped. Present only when the caller passed it.
|
|
100
|
+
def cache_of(raw)
|
|
101
|
+
return nil unless raw.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
c = {
|
|
104
|
+
"hit_pct" => int_or_nil(raw[:hit_pct] || raw["hit_pct"]),
|
|
105
|
+
"cached_tokens" => int(raw[:cached_tokens] || raw["cached_tokens"]),
|
|
106
|
+
"prompt_tokens" => int(raw[:prompt_tokens] || raw["prompt_tokens"]),
|
|
107
|
+
"invalidation_reason" => raw[:invalidation_reason] || raw["invalidation_reason"]
|
|
108
|
+
}
|
|
109
|
+
c["invalidation_reason"] = c["invalidation_reason"].to_s unless c["invalidation_reason"].nil?
|
|
110
|
+
c
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Labels are {name, reason} in string keys (ContextFragment.label). A bare string
|
|
114
|
+
# still reads as a nameless-reason label: an entry recorded before reasons existed,
|
|
115
|
+
# or a caller that only has the id, degrades the card instead of poisoning it.
|
|
116
|
+
def normalize_labels(raw)
|
|
117
|
+
Array(raw).filter_map do |label|
|
|
118
|
+
name, reason = label.is_a?(Hash) ? [label[:name] || label["name"], label[:reason] || label["reason"]] : [label, nil]
|
|
119
|
+
next if name.to_s.empty?
|
|
120
|
+
|
|
121
|
+
{ "name" => name.to_s, "reason" => reason&.to_s }.compact
|
|
122
|
+
end.uniq
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def int(value) = Integer(value || 0)
|
|
126
|
+
def int_or_nil(value) = value.nil? ? nil : Integer(value)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -4,8 +4,8 @@ require "securerandom"
|
|
|
4
4
|
require "time"
|
|
5
5
|
|
|
6
6
|
module Insika
|
|
7
|
-
# Durable record of an ASYNC delegation (
|
|
8
|
-
# "delegation durability"). The synchronous subagent
|
|
7
|
+
# Durable record of an ASYNC delegation (hermes
|
|
8
|
+
# "delegation durability"). The synchronous subagent needs no
|
|
9
9
|
# record — it lives and dies inside the parent's turn. The ASYNC subagent does:
|
|
10
10
|
# the parent DISPATCHES and its turn ends; the child runs independently; when the
|
|
11
11
|
# child finishes, its result is delivered to the parent as a NEW turn (never
|