insika 0.8.0 → 1.0.1
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 +312 -2
- data/README.md +10 -6
- data/bin/insika +4 -1
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +142 -18
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +56 -50
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +96 -27
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +70 -13
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +129 -10
- data/docs/FACTS.md +12 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +14 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +164 -28
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +26 -14
- data/docs/RELEASING.md +9 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +34 -1
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +238 -42
- data/docs/operate.md +3 -0
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +3 -1
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +124 -28
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_distillation.rb +5 -8
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +128 -0
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/builder.rb +29 -9
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +7 -2
- data/lib/insika/context/providers/briefing.rb +11 -8
- data/lib/insika/context/providers/fence_notice.rb +27 -0
- data/lib/insika/context/providers/knowledge.rb +32 -6
- data/lib/insika/context/providers/memory.rb +61 -8
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +46 -6
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +142 -4
- data/lib/insika/dsl/runtime.rb +37 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +19 -5
- data/lib/insika/edge_limiter.rb +27 -16
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +259 -18
- data/lib/insika/evals/golden.rb +230 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +64 -4
- data/lib/insika/evals/simulator.rb +21 -3
- data/lib/insika/evals/transport.rb +317 -15
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +105 -17
- data/lib/insika/executor.rb +698 -164
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +37 -3
- data/lib/insika/mcp_tool_registry.rb +76 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +16 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +30 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +5 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +58 -7
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +50 -5
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +163 -11
- data/lib/insika/settings_store.rb +57 -2
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/spoken_transcript.rb +31 -0
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1459 -163
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +189 -12
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +153 -118
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +115 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +82 -13
- data/lib/insika/tool_envelope.rb +266 -29
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +12 -4
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +14 -4
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +24 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +115 -0
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +9 -6
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +11 -7
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +31 -1
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +88 -16
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +23 -3
- metadata +74 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
|
@@ -36,8 +36,12 @@ module Insika
|
|
|
36
36
|
# platform utility_model) into one history fragment. `prompt` replaces
|
|
37
37
|
# the engine default wholesale (the distill convention). enabled: false
|
|
38
38
|
# = parity (nothing runs). Additive keys — reads overlay DEFAULTS.
|
|
39
|
-
"compaction" => { "enabled" => false, "keep_last" => 20,
|
|
39
|
+
"compaction" => { "enabled" => false, "mode" => "summary", "keep_last" => 20,
|
|
40
40
|
"compact_after" => 40, "model" => nil },
|
|
41
|
+
# Fencing (per-agent `fencing` flag): the cap on ONE string leaf of a tool
|
|
42
|
+
# result after sanitizing. Platform-wide — the leaf size is a context-budget
|
|
43
|
+
# concern, not a persona one. Additive key — reads overlay DEFAULTS.
|
|
44
|
+
"fencing" => { "max_chars" => 12_000 },
|
|
41
45
|
# Data lifecycle (WS8, phase 2): the RETENTION window in days. The
|
|
42
46
|
# tick's Retention sweep purges sessions (+traces), terminal tasks
|
|
43
47
|
# (+checkpoints), memory cells and outcomes older than this. nil/0 =
|
|
@@ -81,7 +85,12 @@ module Insika
|
|
|
81
85
|
"aggregate" => "median",
|
|
82
86
|
"min_agreement" => 0.5,
|
|
83
87
|
"quorum" => 1,
|
|
84
|
-
"tolerance" => 0.05
|
|
88
|
+
"tolerance" => 0.05,
|
|
89
|
+
# seeding -> opens POST /v1/conversations/:id/seed, the route a snapshot eval
|
|
90
|
+
# loads a case's `state:` through. OFF by default: a seeded
|
|
91
|
+
# conversation is a fabricated precondition, and production must
|
|
92
|
+
# not accept one under the tenant token. The doctor warns when on.
|
|
93
|
+
"seeding" => false
|
|
85
94
|
},
|
|
86
95
|
# Edge limits — the platform layer of the EdgeLimiter.
|
|
87
96
|
# nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
|
|
@@ -94,6 +103,23 @@ module Insika
|
|
|
94
103
|
"agent_token_ceiling" => nil,
|
|
95
104
|
"agent_token_window" => 86_400,
|
|
96
105
|
"limit_response" => nil
|
|
106
|
+
},
|
|
107
|
+
# Burst policy — the platform layer of the QueuePolicy (session vars >
|
|
108
|
+
# agent limits > HERE > QueuePolicy::DEFAULTS). nil = no platform default:
|
|
109
|
+
# the key falls through to QueuePolicy::DEFAULTS (followup, no window,
|
|
110
|
+
# steer_max 5, raw text). Same keys the agent config form writes into
|
|
111
|
+
# profile.limits, so one deploy-wide answer no longer means editing every
|
|
112
|
+
# agent. Additive key — reads overlay DEFAULTS.
|
|
113
|
+
# Platform defaults for agent fields (Insika::AgentDefaults): an agent with
|
|
114
|
+
# no value of its own for one of AgentDefaults::FIELDS runs with this one.
|
|
115
|
+
# Written through #put_agent_defaults. Additive key.
|
|
116
|
+
"agent_defaults" => {},
|
|
117
|
+
"queue" => {
|
|
118
|
+
"queue_mode" => nil,
|
|
119
|
+
"debounce_ms" => nil,
|
|
120
|
+
"debounce_max_ms" => nil,
|
|
121
|
+
"steer_max_messages" => nil,
|
|
122
|
+
"steer_join" => nil
|
|
97
123
|
}
|
|
98
124
|
}.freeze
|
|
99
125
|
|
|
@@ -114,6 +140,35 @@ module Insika
|
|
|
114
140
|
merged
|
|
115
141
|
end
|
|
116
142
|
|
|
143
|
+
# Replaces the platform agent defaults per FIELD (AgentDefaults): a sent field
|
|
144
|
+
# replaces its whole value (unlike #update, which deep-merges and could never
|
|
145
|
+
# drop a key the form removed); nil drops it; fields not sent stay.
|
|
146
|
+
# -> Hash (the resulting agent_defaults).
|
|
147
|
+
def put_agent_defaults(patch)
|
|
148
|
+
data = get
|
|
149
|
+
defaults = (data["agent_defaults"] || {}).merge(stringify(patch || {})).compact
|
|
150
|
+
data["agent_defaults"] = defaults
|
|
151
|
+
@cs.put(SCOPE, KEY, data)
|
|
152
|
+
defaults
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Kits (Insika::Kits), replaced per kit: a sent kit replaces its whole value,
|
|
156
|
+
# nil deletes it, other kits stay. -> Hash (all kits).
|
|
157
|
+
def kits = get["kits"] || {}
|
|
158
|
+
|
|
159
|
+
def put_kit(name, kit)
|
|
160
|
+
data = get
|
|
161
|
+
all = (data["kits"] || {}).dup
|
|
162
|
+
if kit.nil?
|
|
163
|
+
all.delete(name.to_s)
|
|
164
|
+
else
|
|
165
|
+
all[name.to_s] = Insika::Kits.validate!(name, kit)
|
|
166
|
+
end
|
|
167
|
+
data["kits"] = all
|
|
168
|
+
@cs.put(SCOPE, KEY, data)
|
|
169
|
+
all
|
|
170
|
+
end
|
|
171
|
+
|
|
117
172
|
# RAW persisted schema version (bypasses the DEFAULTS overlay, which would always
|
|
118
173
|
# report the current one). nil = no settings persisted yet (fresh deploy — nothing
|
|
119
174
|
# to migrate); an Integer otherwise, 0 for a pre-versioning record.
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'digest'
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Required HTTP persistence. The native backend owns the execution outbox;
|
|
8
|
+
# the central service alone owns settled conversation history.
|
|
9
|
+
class SharedConversations
|
|
10
|
+
class Recovered < StandardError
|
|
11
|
+
attr_reader :content, :output_parts, :attachments
|
|
12
|
+
def initialize(content, output_parts: [], attachments: [])
|
|
13
|
+
@content, @output_parts, @attachments = content, output_parts, attachments
|
|
14
|
+
super('recorded shared result recovered')
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
|
|
18
|
+
SCOPE = 'shared_conversations'
|
|
19
|
+
|
|
20
|
+
def initialize(url:, token:, store:)
|
|
21
|
+
require 'uri'
|
|
22
|
+
@url = URI(url)
|
|
23
|
+
unless %w[http https].include?(@url.scheme) && @url.host && !@url.userinfo && !@url.query && !@url.fragment
|
|
24
|
+
raise StoreError, 'invalid shared conversation URL'
|
|
25
|
+
end
|
|
26
|
+
raise StoreError, 'shared conversation token missing' if token.to_s.empty?
|
|
27
|
+
raise StoreError, 'shared conversations require a durable native backend' unless Stores.durable?(store)
|
|
28
|
+
@token, @store = token, store
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def begin_turn(task:, profile:)
|
|
32
|
+
data, payload = identity(task)
|
|
33
|
+
acquire = !payload.fetch('shared_conversation').key?('generation')
|
|
34
|
+
path = path_for(data)
|
|
35
|
+
conversation = request('GET', path)
|
|
36
|
+
key = key_for(data)
|
|
37
|
+
old = @store.get(SCOPE,key)
|
|
38
|
+
check_identity!(data, conversation, ownership: !acquire || !!old)
|
|
39
|
+
if old
|
|
40
|
+
raise StoreError, 'shared turn input differs' unless old['input_digest'] == input_digest(payload)
|
|
41
|
+
raise StoreError, 'shared turn requires native reconciliation' unless old['messages']
|
|
42
|
+
flush(data, old)
|
|
43
|
+
raise Recovered.new(old['messages'].last['content'].filter_map { _1['text'] }.join("\n"), output_parts: old.fetch('output_parts', []), attachments: old.fetch('delivery_attachments', []))
|
|
44
|
+
end
|
|
45
|
+
raise StoreError, 'shared turn requires native reconciliation' if conversation['active_turn']
|
|
46
|
+
data = data.merge('generation'=>conversation['generation'] + (conversation['assigned_harness'] == 'insika' ? 0 : 1)) if acquire
|
|
47
|
+
history = history_at(path, conversation.fetch('last_sequence'))
|
|
48
|
+
request('PUT', path+'/bindings/insika', {'generation'=>data['generation'], 'native_session_id'=>task.session_id || task.id,
|
|
49
|
+
'synchronized_sequence'=>conversation['last_sequence']}) unless acquire
|
|
50
|
+
input = canonical(task, {'role'=>'user','content'=>payload.fetch('user_text')}, id: data['message_id'])
|
|
51
|
+
input['origin'] = payload['origin'] || 'customer'
|
|
52
|
+
input['content'] = data['content'] if data['content']
|
|
53
|
+
record = {'native_run_id'=>task.id, 'input_digest'=>input_digest(payload), 'sequence'=>conversation['last_sequence'] + 1, 'generation'=>data['generation']}
|
|
54
|
+
# Write first: an ambiguous admission or registration must never authorize
|
|
55
|
+
# another execution. Only a recorded result can be flushed automatically.
|
|
56
|
+
@store.transaction do
|
|
57
|
+
raise StoreError, 'shared turn requires native reconciliation' if @store.get(SCOPE,key)
|
|
58
|
+
@store.set(SCOPE,key,record)
|
|
59
|
+
end
|
|
60
|
+
admission = {'id'=>data['turn_id'],'generation'=>data['generation'],
|
|
61
|
+
'expected_sequence'=>conversation['last_sequence'],'message'=>input}
|
|
62
|
+
admission.merge!('acquire'=>true, 'generation'=>conversation['generation'], 'native_session_id'=>task.session_id || task.id) if acquire
|
|
63
|
+
turn = request('POST', path+'/turns', admission)
|
|
64
|
+
raise StoreError, 'shared acquired generation differs' if acquire && turn['generation'] != data['generation']
|
|
65
|
+
raise StoreError, 'shared turn requires native reconciliation' if turn['native_run_id'] || turn['state'] != 'running'
|
|
66
|
+
request('PUT', path+"/turns/#{data['turn_id']}/native-run", {'generation'=>data['generation'],'native_run_id'=>task.id})
|
|
67
|
+
project(history, path)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resume_turn(task:, profile:, checkpoint:)
|
|
71
|
+
data, = identity(task)
|
|
72
|
+
path = path_for(data)
|
|
73
|
+
conversation = request('GET',path)
|
|
74
|
+
check_identity!(data,conversation)
|
|
75
|
+
active = conversation['active_turn']
|
|
76
|
+
record = @store.get(SCOPE,key_for(data))
|
|
77
|
+
valid = task.status == :waiting && checkpoint.task_id == task.id && checkpoint.continuation &&
|
|
78
|
+
active && active.values_at('id','native_run_id','state') == [data['turn_id'],task.id,'running'] &&
|
|
79
|
+
record && record['native_run_id'] == task.id
|
|
80
|
+
raise StoreError, 'shared turn requires native reconciliation' unless valid
|
|
81
|
+
project(history_at(path,record['sequence'] - 1),path)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def complete(task:, messages:, pending_approval: false, output_parts: [], attachments: [])
|
|
85
|
+
data, = identity(task)
|
|
86
|
+
record = @store.get(SCOPE,key_for(data)) || raise(StoreError, 'shared admission missing')
|
|
87
|
+
raise StoreError, 'shared native execution differs' unless record['native_run_id'] == task.id
|
|
88
|
+
unless record['messages']
|
|
89
|
+
output = messages.drop_while { _1['role'] == 'user' }
|
|
90
|
+
raise StoreError, 'shared final answer missing' unless output.last && output.last['role'] == 'assistant'
|
|
91
|
+
record['pending_approval'] = pending_approval
|
|
92
|
+
record['messages'] = output.map do |message|
|
|
93
|
+
full = record.fetch('tool_results', {})[message['tool_call_id']]
|
|
94
|
+
if message['role'] == 'tool' && !full.nil?
|
|
95
|
+
text = full.is_a?(String) ? full : JSON.generate(full)
|
|
96
|
+
message = message.merge('content'=>text)
|
|
97
|
+
end
|
|
98
|
+
canonical(task, message)
|
|
99
|
+
end
|
|
100
|
+
record['output_parts'] = Array(output_parts)
|
|
101
|
+
record['delivery_attachments'] = Array(attachments)
|
|
102
|
+
record['uploads'] = Array(output_parts).map do |part|
|
|
103
|
+
require 'base64'
|
|
104
|
+
raise StoreError, 'invalid shared output media' unless %w[image audio].include?(part['type']) && part['base64'].is_a?(String)
|
|
105
|
+
bytes = Base64.strict_decode64(part['base64'])
|
|
106
|
+
raise StoreError, 'shared output exceeds 10 MiB' if bytes.bytesize > 10*1024*1024
|
|
107
|
+
id = SecureRandom.uuid
|
|
108
|
+
record['messages'].last['content'] << {'type'=>part['type'],'attachment_id'=>id}
|
|
109
|
+
{'id'=>id,'base64'=>part['base64'],'mime_type'=>part.fetch('mime_type'),'sha256'=>Digest::SHA256.hexdigest(bytes)}
|
|
110
|
+
end
|
|
111
|
+
@store.set(SCOPE,key_for(data),record)
|
|
112
|
+
end
|
|
113
|
+
flush(data,record)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def input_attachments(task:)
|
|
117
|
+
data, = identity(task)
|
|
118
|
+
return [] unless data['content']
|
|
119
|
+
project([{'role'=>'user', 'content'=>data['content']}],path_for(data)).first['attachments'].map { Media.hydrate_attachment(_1) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def record_tool_result(task:, call_id:, result:)
|
|
123
|
+
raise StoreError, 'shared tool correlation missing' if call_id.to_s.empty?
|
|
124
|
+
data, = identity(task)
|
|
125
|
+
@store.transaction do
|
|
126
|
+
record = @store.get(SCOPE,key_for(data)) || raise(StoreError, 'shared admission missing')
|
|
127
|
+
results = (record['tool_results'] ||= {})
|
|
128
|
+
# The first capture precedes prompt-only evidence reshaping and clipping.
|
|
129
|
+
results[call_id.to_s] ||= ToolTraceStore.mask(result.respond_to?(:content) ? result.content : result)
|
|
130
|
+
@store.set(SCOPE,key_for(data),record)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def identity(task)
|
|
135
|
+
payload = task.command.fetch('payload')
|
|
136
|
+
data = payload['shared_conversation']
|
|
137
|
+
raise StoreError, 'explicit shared conversation identity required' unless data.is_a?(Hash)
|
|
138
|
+
%w[tenant_id user_id agent_id conversation_id turn_id message_id].each do |name|
|
|
139
|
+
raise StoreError, "invalid shared #{name}" unless data[name].is_a?(String) && UUID.match?(data[name])
|
|
140
|
+
end
|
|
141
|
+
if data.key?('generation')
|
|
142
|
+
raise StoreError, 'invalid shared generation' unless data['generation'].is_a?(Integer) && data['generation'].positive?
|
|
143
|
+
else
|
|
144
|
+
record = @store.get(SCOPE,key_for(data))
|
|
145
|
+
data = data.merge('generation'=>record.fetch('generation')) if record
|
|
146
|
+
end
|
|
147
|
+
raise StoreError, 'original user_text required' unless payload['user_text'].is_a?(String)
|
|
148
|
+
content = data['content']
|
|
149
|
+
if content
|
|
150
|
+
valid = content.is_a?(Array) && content.all? do |part|
|
|
151
|
+
part.is_a?(Hash) && (part['type'] == 'text' ? part['text'].is_a?(String) :
|
|
152
|
+
%w[image audio document].include?(part['type']) && part['attachment_id'].is_a?(String) && UUID.match?(part['attachment_id']))
|
|
153
|
+
end
|
|
154
|
+
raise StoreError, 'invalid shared content' unless valid
|
|
155
|
+
raise StoreError, 'shared content differs from original speech' unless content.filter_map { _1['text'] }.join("\n") == payload['user_text']
|
|
156
|
+
end
|
|
157
|
+
if Media.parts(payload['parts']).any? { !_1.text? } && !Array(content).any? { _1['type'] != 'text' }
|
|
158
|
+
raise StoreError, 'upload media to central storage before admission'
|
|
159
|
+
end
|
|
160
|
+
tenant = task.command.fetch('meta')['tenant']
|
|
161
|
+
raise StoreError, 'shared tenant identity differs' unless tenant.to_s == data['tenant_id']
|
|
162
|
+
[data,payload]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def memory_context(task:, profile:)
|
|
166
|
+
data, payload = identity(task)
|
|
167
|
+
result = {}
|
|
168
|
+
if profile.memory
|
|
169
|
+
refs = request('GET', "/v1/memories/user/#{data['user_id']}?limit=50")['memories']
|
|
170
|
+
result['facts'] = refs.filter_map { memory_get(task:task,id:_1['id']) }
|
|
171
|
+
path = "/v1/memories/conversation/#{data['conversation_id']}"
|
|
172
|
+
request('GET',path+'?limit=50')['memories'].each do |ref|
|
|
173
|
+
record = request('GET',path+'/'+URI.encode_www_form_component(ref['id']),missing:true)
|
|
174
|
+
result['facts'] << record.merge('id'=>"conversation.#{record['id']}") if record
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
if Coercion.truthy?(profile.knowledge&.dig('retrieve'))
|
|
178
|
+
query = URI.encode_www_form_component(payload['user_text'][0, 200])
|
|
179
|
+
result['knowledge'] = request('GET', "/v1/memories/agent/#{data['agent_id']}?limit=5&query=#{query}")['memories']
|
|
180
|
+
end
|
|
181
|
+
result
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def memory_get(task:, id:, kind: 'fact', include_proposed: false)
|
|
185
|
+
data, = identity(task)
|
|
186
|
+
scope, owner = kind == 'knowledge' ? ['agent',data['agent_id']] : ['user',data['user_id']]
|
|
187
|
+
path = "/v1/memories/#{scope}/#{owner}/#{URI.encode_www_form_component(id)}"
|
|
188
|
+
path += '?include_proposed=true' if include_proposed
|
|
189
|
+
request('GET', path, missing:true)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def propose_memory(task:, id:, value:, kind: 'fact')
|
|
193
|
+
data, = identity(task)
|
|
194
|
+
scope, owner = kind == 'knowledge' ? ['agent',data['agent_id']] : ['user',data['user_id']]
|
|
195
|
+
current = memory_get(task:task,id:id,kind:kind,include_proposed:true)
|
|
196
|
+
recorded = @store.get(SCOPE,key_for(data))
|
|
197
|
+
source_ids = [data['message_id']] + Array(recorded && recorded['messages']).map { _1['id'] }
|
|
198
|
+
clean = value.is_a?(String) ? Safety::Detectors.redact(value).first : ToolTraceStore.mask(value)
|
|
199
|
+
request('PUT', "/v1/memories/#{scope}/#{owner}/#{URI.encode_www_form_component(id)}",
|
|
200
|
+
{'value'=>clean,'kind'=>kind,'origin'=>'insika','status'=>'proposed','expected_revision'=>current ? current['revision'] : 0,
|
|
201
|
+
'sources'=>source_ids.uniq.map { {'conversation_id'=>data['conversation_id'],'message_id'=>_1} }})
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def check_identity!(data, conversation, ownership: true)
|
|
207
|
+
unless conversation.values_at('tenant_id','user_id','agent_id') == data.values_at('tenant_id','user_id','agent_id') &&
|
|
208
|
+
(!ownership || conversation.values_at('assigned_harness','generation') == ['insika',data['generation']])
|
|
209
|
+
raise StoreError, 'shared conversation identity or generation mismatch'
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def key_for(data) = data.values_at('tenant_id','conversation_id','turn_id').join(':')
|
|
214
|
+
def path_for(data) = "/v1/conversations/#{data['conversation_id']}"
|
|
215
|
+
def input_digest(payload) = Digest::SHA256.hexdigest(JSON.generate(payload.slice('shared_conversation','user_text','origin')))
|
|
216
|
+
|
|
217
|
+
def canonical(task, message, id: SecureRandom.uuid)
|
|
218
|
+
result = {'schema_version'=>1,'id'=>id,'role'=>message.fetch('role'),
|
|
219
|
+
'content'=>[{'type'=>'text','text'=>message.fetch('content').to_s}],
|
|
220
|
+
'source'=>{'harness'=>'insika','session_id'=>task.session_id || task.id}}
|
|
221
|
+
result.merge!(ToolTraceStore.mask(message.slice('tool_calls','tool_call_id','origin','outcome')))
|
|
222
|
+
result
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def flush(data, record)
|
|
226
|
+
require 'base64'
|
|
227
|
+
record.fetch('uploads',[]).each do |upload|
|
|
228
|
+
request('PUT',path_for(data)+"/attachments/#{upload['id']}",Base64.strict_decode64(upload['base64']),
|
|
229
|
+
headers: {'Content-Type'=>upload['mime_type'],'X-Filename'=>upload['id'],
|
|
230
|
+
'X-Content-SHA256'=>upload['sha256'],'X-Conversation-Generation'=>data['generation'].to_s})
|
|
231
|
+
end
|
|
232
|
+
path = path_for(data)+"/turns/#{data['turn_id']}"
|
|
233
|
+
prefix = record.fetch('messages')[0...-1]
|
|
234
|
+
prefix.each_with_index do |message, index|
|
|
235
|
+
request('POST', path+'/messages', {'generation'=>data['generation'],'expected_sequence'=>record['sequence']+index,'messages'=>[message]})
|
|
236
|
+
end
|
|
237
|
+
request('POST', path+'/complete', {'generation'=>data['generation'],
|
|
238
|
+
'expected_sequence'=>record['sequence']+prefix.length,'message'=>record['messages'].last,'pending_approval'=>record.fetch('pending_approval',false)})
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def history_at(path, sequence)
|
|
242
|
+
messages, cursor = [], 0
|
|
243
|
+
while cursor < sequence
|
|
244
|
+
page = request('GET', path+"/messages?after_sequence=#{cursor}&through_sequence=#{sequence}&limit=10")
|
|
245
|
+
raise StoreError, 'shared history cursor did not advance' unless page['next_sequence'].is_a?(Integer) && page['next_sequence'] > cursor
|
|
246
|
+
messages.concat(page.fetch('messages'))
|
|
247
|
+
cursor = page['next_sequence']
|
|
248
|
+
end
|
|
249
|
+
messages
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def project(messages, path)
|
|
253
|
+
messages.map do |message|
|
|
254
|
+
text, attachments = [], []
|
|
255
|
+
message.fetch('content').each do |part|
|
|
256
|
+
if part['type'] == 'text'
|
|
257
|
+
text << part.fetch('text')
|
|
258
|
+
else
|
|
259
|
+
id = part.fetch('attachment_id')
|
|
260
|
+
raise StoreError, 'invalid shared attachment ID' unless UUID.match?(id)
|
|
261
|
+
bytes, headers = request('GET', path+"/attachments/#{id}", raw: true)
|
|
262
|
+
raise StoreError, 'shared attachment hash mismatch' unless Digest::SHA256.hexdigest(bytes) == headers['x-content-sha256']
|
|
263
|
+
require 'base64'
|
|
264
|
+
extension = {'image/png'=>'png','image/jpeg'=>'jpg','image/webp'=>'webp','image/gif'=>'gif',
|
|
265
|
+
'application/pdf'=>'pdf','audio/mpeg'=>'mp3','audio/wav'=>'wav','audio/ogg'=>'ogg','audio/webm'=>'webm','text/plain'=>'txt'}.fetch(headers['content-type'],'bin')
|
|
266
|
+
attachments << {'base64'=>Base64.strict_encode64(bytes),'filename'=>"#{id}.#{extension}"}
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
projected = message.slice('role','origin').merge('content'=>text.join("\n"),'attachments'=>attachments)
|
|
270
|
+
# RubyLLM checks answered call IDs across its entire chat, not per turn.
|
|
271
|
+
call_id = ->(id) { "history_#{Digest::SHA256.hexdigest("#{message.fetch('turn_id')}:#{id}")[0, 48]}" }
|
|
272
|
+
projected['tool_calls'] = message['tool_calls'].map { _1.merge('id'=>call_id.call(_1['id'])) } if message['tool_calls']
|
|
273
|
+
projected['tool_call_id'] = call_id.call(message['tool_call_id']) if message['tool_call_id']
|
|
274
|
+
projected
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def request(method, path, data = nil, raw: false, headers: {}, missing: false)
|
|
279
|
+
require 'net/http'
|
|
280
|
+
uri = @url.dup
|
|
281
|
+
uri.path = @url.path.delete_suffix('/') + path.split('?',2).first
|
|
282
|
+
uri.query = path.split('?',2)[1]
|
|
283
|
+
body = data.is_a?(String) ? data : data && JSON.generate(data)
|
|
284
|
+
raise StoreError, 'shared request exceeds 1 MiB' if body && !data.is_a?(String) && body.bytesize > 1_048_576
|
|
285
|
+
request = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
|
|
286
|
+
{'Authorization'=>"Bearer #{@token}", 'Content-Type'=>'application/json'}.merge(headers))
|
|
287
|
+
request.body = body if body
|
|
288
|
+
Net::HTTP.start(uri.host, uri.port, nil, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 10, write_timeout: 10) do |http|
|
|
289
|
+
http.max_retries = 0
|
|
290
|
+
http.request(request) do |response|
|
|
291
|
+
return nil if missing && response.code == '404'
|
|
292
|
+
raise StoreError, "shared conversation HTTP #{response.code}" unless response.code == '200'
|
|
293
|
+
bytes = +''.b
|
|
294
|
+
response.read_body do |chunk|
|
|
295
|
+
bytes << chunk
|
|
296
|
+
raise StoreError, 'shared response too large' if bytes.bytesize > (raw ? 10 : 16)*1024*1024
|
|
297
|
+
end
|
|
298
|
+
return raw ? [bytes,response.to_hash.transform_values(&:first)] : JSON.parse(bytes)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error, SocketError => error
|
|
302
|
+
raise StoreError, "shared conversation transport failed (#{error.class})"
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
data/lib/insika/skill_catalog.rb
CHANGED
|
@@ -28,10 +28,17 @@ module Insika
|
|
|
28
28
|
# store (optional): a SkillStore with the skills AUTHORED in the Studio.
|
|
29
29
|
# They overlay the on-disk ones (seed) — the Store wins, it is the source of truth.
|
|
30
30
|
# Nil = disk-only behavior, zero regression.
|
|
31
|
-
|
|
31
|
+
#
|
|
32
|
+
# recheck/clock: another worker may write a skill (pack import, Studio edit) and
|
|
33
|
+
# `reload` only runs in the process that handled the write. At most every
|
|
34
|
+
# `recheck` seconds the catalog compares the config version token and reloads
|
|
35
|
+
# when it moved — the same rule as OverlayToolRegistry for data-tools.
|
|
36
|
+
def initialize(roots, store: nil, recheck: 2, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
32
37
|
@roots = Array(roots)
|
|
33
38
|
@store = store
|
|
34
|
-
@
|
|
39
|
+
@recheck = recheck
|
|
40
|
+
@clock = clock
|
|
41
|
+
reload
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
# The SkillStore the catalog overlays — the composition root hands it to
|
|
@@ -49,6 +56,7 @@ module Insika
|
|
|
49
56
|
# Without `agent` the shared scope is all there is, which is what every caller
|
|
50
57
|
# that has no agent in hand (the Studio's shared editor, a bare catalog) means.
|
|
51
58
|
def all(agent: nil)
|
|
59
|
+
reload if stale?
|
|
52
60
|
shared = @skills
|
|
53
61
|
overrides = agent_scope(agent)
|
|
54
62
|
return shared.values if overrides.empty?
|
|
@@ -57,6 +65,7 @@ module Insika
|
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
def find(name, agent: nil)
|
|
68
|
+
reload if stale?
|
|
60
69
|
agent_scope(agent)[name.to_s] || @skills[name.to_s]
|
|
61
70
|
end
|
|
62
71
|
|
|
@@ -64,6 +73,8 @@ module Insika
|
|
|
64
73
|
# authored/edited skill takes effect without a restart. A turn in progress
|
|
65
74
|
# captured @skills at dispatch, so it does not see the swap mid-flight.
|
|
66
75
|
def reload
|
|
76
|
+
@token = version_token
|
|
77
|
+
@checked_at = @clock.call
|
|
67
78
|
@skills, @agent_skills = load_all
|
|
68
79
|
self
|
|
69
80
|
end
|
|
@@ -155,6 +166,21 @@ module Insika
|
|
|
155
166
|
# AgentProfile#stream_public?. Anything else (a list, nil, false) is not blanket.
|
|
156
167
|
def blanket?(spec) = Coercion.truthy?(spec)
|
|
157
168
|
|
|
169
|
+
# The token moves on any config write, so a reload may find nothing new. Read
|
|
170
|
+
# BEFORE load_all in `reload`: a write landing in between leaves the old token,
|
|
171
|
+
# and the next check reloads again instead of missing it.
|
|
172
|
+
def stale?
|
|
173
|
+
return false unless @store.respond_to?(:version_token)
|
|
174
|
+
|
|
175
|
+
now = @clock.call
|
|
176
|
+
return false if now - @checked_at < @recheck
|
|
177
|
+
|
|
178
|
+
@checked_at = now
|
|
179
|
+
version_token != @token
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def version_token = @store.respond_to?(:version_token) ? @store.version_token : nil
|
|
183
|
+
|
|
158
184
|
# An agent's override index; {} for a nil agent or one that specialized nothing.
|
|
159
185
|
def agent_scope(agent) = agent.nil? ? {} : (@agent_skills[agent.to_s] || {})
|
|
160
186
|
|
data/lib/insika/skill_store.rb
CHANGED
|
@@ -55,6 +55,9 @@ module Insika
|
|
|
55
55
|
names(agent: agent).each_with_object({}) { |n, acc| acc[n] = get(n, agent: agent) }
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# Changes whenever any config is written, in any process (ConfigStore#version_token).
|
|
59
|
+
def version_token = @cs.version_token
|
|
60
|
+
|
|
58
61
|
# -> [String] every agent that has specialized at least one skill. What the
|
|
59
62
|
# catalog overlays and `doctor` sweeps.
|
|
60
63
|
def agents = @cs.keys(AGENT_SCOPE).sort
|
|
@@ -86,6 +89,9 @@ module Insika
|
|
|
86
89
|
# -> [ { "content" =>, "at" => } ] most recent first.
|
|
87
90
|
def versions(name, agent: nil) = record(name, agent)&.fetch("history", []) || []
|
|
88
91
|
|
|
92
|
+
# -> iso8601 of the current content's write, nil for an unknown skill.
|
|
93
|
+
def updated_at(name, agent: nil) = record(name, agent)&.fetch("updated_at", nil)
|
|
94
|
+
|
|
89
95
|
# Restores version `index` as the current content (a new write). -> Hash.
|
|
90
96
|
def restore(name, index, agent: nil)
|
|
91
97
|
hist = versions(name, agent: agent)
|
data/lib/insika/soak/runner.rb
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# The transcript slice an EXTRACTOR reads — memory distillation, knowledge
|
|
5
|
+
# extraction. Only what people said: `user` and `assistant` prose. A
|
|
6
|
+
# `role: tool` message is a product description, a search result, an FAQ body
|
|
7
|
+
# — third-party text; a customer fact or a learned concept distilled from it
|
|
8
|
+
# is a defect, not a behaviour to preserve. Tool-call payloads never render
|
|
9
|
+
# either (only `content` does). Indices are the ORIGINAL message positions,
|
|
10
|
+
# so a proposal's `turns` still point at the right message.
|
|
11
|
+
module SpokenTranscript
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
ROLES = %w[user assistant].freeze
|
|
15
|
+
|
|
16
|
+
# -> String, PII-redacted (what reaches the utility model follows the same
|
|
17
|
+
# redaction rule as what gets persisted). Messages are store records: string
|
|
18
|
+
# keys, like everything at the persistence boundary.
|
|
19
|
+
def render(messages)
|
|
20
|
+
lines = Array(messages).each_with_index.filter_map do |m, i|
|
|
21
|
+
role = m["role"].to_s
|
|
22
|
+
content = m["content"].to_s
|
|
23
|
+
next unless ROLES.include?(role) && !content.strip.empty?
|
|
24
|
+
|
|
25
|
+
"[#{i}] #{role}: #{content}"
|
|
26
|
+
end
|
|
27
|
+
redacted, = Insika::Safety::Detectors.redact(lines.join("\n"))
|
|
28
|
+
redacted
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -6,17 +6,9 @@ module Insika
|
|
|
6
6
|
#
|
|
7
7
|
# A customer who corrects themselves while the agent is calling tools ("1234567",
|
|
8
8
|
# three seconds after "queria saber do pedido") should have that land before the
|
|
9
|
-
# model's next reasoning step, not after the whole run. RubyLLM
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# complete_once
|
|
14
|
-
# ├─ provider_completion → assistant message announcing N tool_calls
|
|
15
|
-
# ├─ after_message(assistant) ← N is read here
|
|
16
|
-
# └─ handle_tool_calls
|
|
17
|
-
# ├─ add_tool_result_message ×N
|
|
18
|
-
# │ └─ after_message(tool) ← counted; the Nth is THE BOUNDARY
|
|
19
|
-
# └─ halt_result || complete ← the next model step sees what we appended
|
|
9
|
+
# model's next reasoning step, not after the whole run. RubyLLM emits message
|
|
10
|
+
# callbacks during both `chat.ask` and native steps. ToolBatch finds the last
|
|
11
|
+
# sibling result so steering lands before the next model step.
|
|
20
12
|
#
|
|
21
13
|
# Counting to N is not an optimization, it is the correctness condition. A `user`
|
|
22
14
|
# message inserted BETWEEN tool results is rejected outright by Anthropic (all tool
|
|
@@ -41,9 +33,7 @@ module Insika
|
|
|
41
33
|
@actor = actor
|
|
42
34
|
@policy = policy
|
|
43
35
|
@emit = emit
|
|
44
|
-
@
|
|
45
|
-
@seen = 0
|
|
46
|
-
@halted = false
|
|
36
|
+
@batch = ToolBatch.new
|
|
47
37
|
@injected = 0
|
|
48
38
|
end
|
|
49
39
|
|
|
@@ -53,20 +43,13 @@ module Insika
|
|
|
53
43
|
# RubyLLM `after_tool_result`, with the RAW result — the only place a `Tool::Halt`
|
|
54
44
|
# is still recognizable. By the time it becomes a `role: tool` message its content
|
|
55
45
|
# is the payload, indistinguishable from an ordinary result.
|
|
56
|
-
def tool_result(result)
|
|
57
|
-
@halted = true if halt?(result)
|
|
58
|
-
end
|
|
46
|
+
def tool_result(result) = @batch.halt!(result)
|
|
59
47
|
|
|
60
48
|
# RubyLLM `after_message`. An assistant message carrying tool calls OPENS a batch;
|
|
61
49
|
# the Nth tool result CLOSES it, and that is the one boundary where appending is
|
|
62
50
|
# valid.
|
|
63
51
|
def message_ended(message)
|
|
64
|
-
|
|
65
|
-
return open_batch(message) if role == "assistant"
|
|
66
|
-
return unless role == "tool" && @expected
|
|
67
|
-
|
|
68
|
-
@seen += 1
|
|
69
|
-
inject! if @seen >= @expected
|
|
52
|
+
inject! if @batch.closed?(message)
|
|
70
53
|
end
|
|
71
54
|
|
|
72
55
|
# Tail-appends whatever is in the mailbox right now and reports how many. Called
|
|
@@ -88,34 +71,10 @@ module Insika
|
|
|
88
71
|
|
|
89
72
|
private
|
|
90
73
|
|
|
91
|
-
def open_batch(message)
|
|
92
|
-
calls = field(message, :tool_calls)
|
|
93
|
-
size = calls.respond_to?(:size) ? calls.size : 0
|
|
94
|
-
# A message with no tool call is the model talking, not a batch: leave any
|
|
95
|
-
# pending message where it is. The turn is about to end, and the Executor
|
|
96
|
-
# absorbs it there in ONE extra round (#absorb_pending!) — a text-only turn
|
|
97
|
-
# closes no batch, and answering it half-way is not an option.
|
|
98
|
-
return @expected = nil if size.zero?
|
|
99
|
-
|
|
100
|
-
@expected = size
|
|
101
|
-
@seen = 0
|
|
102
|
-
@halted = false
|
|
103
|
-
end
|
|
104
|
-
|
|
105
74
|
def inject!
|
|
106
|
-
@
|
|
107
|
-
return if @halted # nothing will read it: leave it in the mailbox
|
|
75
|
+
return if @batch.halted? # nothing will read it: leave it in the mailbox
|
|
108
76
|
|
|
109
77
|
absorb_pending!
|
|
110
78
|
end
|
|
111
|
-
|
|
112
|
-
def halt?(result) = defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
|
|
113
|
-
|
|
114
|
-
def field(message, name)
|
|
115
|
-
return message.public_send(name) if message.respond_to?(name)
|
|
116
|
-
return message[name] || message[name.to_s] if message.respond_to?(:[])
|
|
117
|
-
|
|
118
|
-
nil
|
|
119
|
-
end
|
|
120
79
|
end
|
|
121
80
|
end
|
data/lib/insika/store.rb
CHANGED
|
@@ -25,7 +25,9 @@ module Insika
|
|
|
25
25
|
#
|
|
26
26
|
# Backends `include Store` and override the six methods; any forgotten
|
|
27
27
|
# method raises NotImplementedError (fail-fast, better than a distant
|
|
28
|
-
# NoMethodError).
|
|
28
|
+
# NoMethodError). #entries, #recent and #amend have defaults; a backend that
|
|
29
|
+
# knows its write order overrides #recent and #amend together (the contract
|
|
30
|
+
# suite checks that an amended key keeps its place in #recent).
|
|
29
31
|
module Store
|
|
30
32
|
# -> Object | nil (deserialized)
|
|
31
33
|
def get(scope, key)
|
|
@@ -37,6 +39,13 @@ module Insika
|
|
|
37
39
|
raise NotImplementedError, "#{self.class}#set"
|
|
38
40
|
end
|
|
39
41
|
|
|
42
|
+
# -> value. Like #set, but an existing key keeps its place in #recent: an edit
|
|
43
|
+
# that is not new activity (a review on a session). This default is #set,
|
|
44
|
+
# exact only for a backend whose #recent reads the value's "updated_at".
|
|
45
|
+
def amend(scope, key, value)
|
|
46
|
+
set(scope, key, value)
|
|
47
|
+
end
|
|
48
|
+
|
|
40
49
|
# -> true | false (did it exist?)
|
|
41
50
|
def delete(scope, key)
|
|
42
51
|
raise NotImplementedError, "#{self.class}#delete"
|
|
@@ -53,6 +62,25 @@ module Insika
|
|
|
53
62
|
raise NotImplementedError, "#{self.class}#scopes"
|
|
54
63
|
end
|
|
55
64
|
|
|
65
|
+
# -> [[key, value], ...] sorted by key, filtered like #list. The bulk read for
|
|
66
|
+
# a caller that lists keys and reads each one. This default is exactly that;
|
|
67
|
+
# a backend overrides it to answer in one query (no lock taken, like #list).
|
|
68
|
+
def entries(scope, prefix = nil)
|
|
69
|
+
list(scope, prefix).filter_map do |key|
|
|
70
|
+
value = get(scope, key)
|
|
71
|
+
[key, value] unless value.nil?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -> [[key, value], ...] the `limit` most recently WRITTEN keys of the scope
|
|
76
|
+
# (filtered like #list), newest first: a "recent" list without reading the
|
|
77
|
+
# whole scope. A backend that knows its write order answers it directly;
|
|
78
|
+
# this default approximates it by each value's "updated_at", when it has one.
|
|
79
|
+
# `offset` skips that many of the newest (the next page).
|
|
80
|
+
def recent(scope, prefix, limit, offset = 0)
|
|
81
|
+
entries(scope, prefix).sort_by { |_, v| v.is_a?(Hash) ? v["updated_at"].to_s : "" }.reverse.drop(offset).first(limit)
|
|
82
|
+
end
|
|
83
|
+
|
|
56
84
|
# -> the block's result; atomic if the backend supports it
|
|
57
85
|
def transaction(&blk)
|
|
58
86
|
raise NotImplementedError, "#{self.class}#transaction"
|