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
data/lib/insika/pack_importer.rb
CHANGED
|
@@ -17,6 +17,10 @@ module Insika
|
|
|
17
17
|
# tools rewritten; the allowlists (prompt_files/skills/tools_allow) are
|
|
18
18
|
# AUTHORITATIVE from the pack, so whatever left the pack leaves the agent
|
|
19
19
|
# (isolation and no drift). An in-flight turn keeps the profile it captured.
|
|
20
|
+
#
|
|
21
|
+
# `limits` and `params` are the exception, and the only one: they MERGE per key
|
|
22
|
+
# (see KNOB_BAGS). A client that publishes two limits is not saying the agent has
|
|
23
|
+
# only two.
|
|
20
24
|
class PackImporter
|
|
21
25
|
def initialize(bus:, profiles:)
|
|
22
26
|
@bus = bus
|
|
@@ -30,13 +34,14 @@ module Insika
|
|
|
30
34
|
id = presence(pack.config[:id]) ||
|
|
31
35
|
(raise Insika::ValidationError, "pack missing config.id")
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
existing = @profiles[id]
|
|
38
|
+
created = existing.nil?
|
|
34
39
|
# create_agent rejects an already-existing id; update_agent requires it to
|
|
35
40
|
# exist — the choice by presence makes the import an upsert.
|
|
36
|
-
dispatch(created ? :create_agent : :update_agent, agent_attrs(pack, id))
|
|
41
|
+
dispatch(created ? :create_agent : :update_agent, agent_attrs(pack, id, existing))
|
|
37
42
|
|
|
38
43
|
pack.files.each { |name, body| dispatch(:write_agent_file, { agent_id: id, file: name, content: body }) }
|
|
39
|
-
pack.skills.each { |name, body| dispatch(:write_skill, { name: name, content: body }) }
|
|
44
|
+
pack.skills.each { |name, body| dispatch(:write_skill, { name: name, content: body, agent: id }) }
|
|
40
45
|
pack.tools.each { |defn| dispatch(:write_data_tool, defn) }
|
|
41
46
|
|
|
42
47
|
{ agent_id: id, created: created,
|
|
@@ -44,8 +49,8 @@ module Insika
|
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
# Removes the agent (delete_agent). Does NOT delete skills/tools/files: they
|
|
47
|
-
# may be
|
|
48
|
-
# operator work. NotFoundError (missing agent) propagates -> 404.
|
|
52
|
+
# may be needed again on re-import; tools may also be shared. Selective removal
|
|
53
|
+
# is operator work. NotFoundError (missing agent) propagates -> 404.
|
|
49
54
|
def delete(id)
|
|
50
55
|
dispatch(:delete_agent, { id: id })
|
|
51
56
|
{ agent_id: id, deleted: true }
|
|
@@ -58,8 +63,10 @@ module Insika
|
|
|
58
63
|
# its OWN pack (per-store isolation) and re-provisioning removes what left.
|
|
59
64
|
# - prompt_files = the pack's .md files (write_agent_file also registers;
|
|
60
65
|
# union is a no-op). Setting here makes the list authoritative (removes the ones that left).
|
|
61
|
-
# - skills = the pack's skills/ dirs (
|
|
62
|
-
#
|
|
66
|
+
# - skills = the pack's skills/ dirs ∪ config.skills (names of SHARED skills the
|
|
67
|
+
# pack references without copying — one body serves every agent that lists
|
|
68
|
+
# it). [] when both are empty — never nil=all, which would leak skills from
|
|
69
|
+
# other stores.
|
|
63
70
|
# - tools_allow = (config.tools_allow) ∪ (the pack's tool names) — guarantees
|
|
64
71
|
# the agent can call its own data-tools. [] when neither exists —
|
|
65
72
|
# never nil=all: the ToolStore is GLOBAL, so a tool-less pack would
|
|
@@ -70,11 +77,12 @@ module Insika
|
|
|
70
77
|
# the tools of the enabled groups (union with tools_allow) go to the model;
|
|
71
78
|
# those of disabled groups are cut BEFORE the turn (resolves the OpenClaw
|
|
72
79
|
# tool-call waste, where the flag only exists in Rails).
|
|
73
|
-
def agent_attrs(pack, id)
|
|
80
|
+
def agent_attrs(pack, id, existing = nil)
|
|
74
81
|
attrs = pack.config.dup
|
|
75
82
|
attrs[:id] = id
|
|
83
|
+
merge_knob_bags!(attrs, existing)
|
|
76
84
|
attrs[:prompt_files] = pack.files.keys unless pack.files.empty?
|
|
77
|
-
attrs[:skills] = pack.skills.keys
|
|
85
|
+
attrs[:skills] = pack.skills.keys | Array(pack.config[:skills]).map(&:to_s)
|
|
78
86
|
|
|
79
87
|
pack_tools = pack.tools.map { |t| tool_name(t) }
|
|
80
88
|
allow = Array(pack.config[:tools_allow]).map(&:to_s) | pack_tools
|
|
@@ -85,6 +93,43 @@ module Insika
|
|
|
85
93
|
attrs
|
|
86
94
|
end
|
|
87
95
|
|
|
96
|
+
# The bags a re-import must not ERASE. `limits` and `params` are flat
|
|
97
|
+
# `key => scalar` maps whose keys are independent of one another: a provisioning
|
|
98
|
+
# client that sends two of them is saying something about those two and nothing
|
|
99
|
+
# about the rest. `update_agent` replaces a sent field wholesale (correct for the
|
|
100
|
+
# Studio, which always posts the complete bag), so without this a client that
|
|
101
|
+
# publishes `limits: {turn_timeout:, context_budget:}` silently drops every knob
|
|
102
|
+
# an operator set here — `queue_mode`, `debounce_ms`, `chat_rate_limit` — on the
|
|
103
|
+
# next publish of an unrelated playbook.
|
|
104
|
+
#
|
|
105
|
+
# NOT the authoritative list (`prompt_files`/`skills`/`tools_allow`), which the
|
|
106
|
+
# pack is supposed to erase: those are isolation boundaries and a name that left
|
|
107
|
+
# the pack must leave the agent. A knob is not a boundary.
|
|
108
|
+
#
|
|
109
|
+
# The pack still WINS per key. What it cannot do is remove a key by omission —
|
|
110
|
+
# removing a knob is Studio (or operator) work, which is where it was set.
|
|
111
|
+
KNOB_BAGS = %i[limits params].freeze
|
|
112
|
+
|
|
113
|
+
# Keys are symbolized on BOTH sides before merging: the profile holds symbols and
|
|
114
|
+
# the pack's nested hashes arrive with whatever the JSON had, so a raw merge would
|
|
115
|
+
# leave `:turn_timeout` and `"turn_timeout"` side by side and let the later
|
|
116
|
+
# normalization pick by insertion order.
|
|
117
|
+
def merge_knob_bags!(attrs, existing)
|
|
118
|
+
return if existing.nil?
|
|
119
|
+
|
|
120
|
+
KNOB_BAGS.each do |bag|
|
|
121
|
+
patch = attrs[bag]
|
|
122
|
+
next unless patch.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
current = existing.respond_to?(bag) ? existing.public_send(bag) : nil
|
|
125
|
+
next unless current.is_a?(Hash)
|
|
126
|
+
|
|
127
|
+
attrs[bag] = sym_keys(current).merge(sym_keys(patch))
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def sym_keys(hash) = hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
132
|
+
|
|
88
133
|
# PER-FLAG CUT (/, STATIC pilot): derives the agent's
|
|
89
134
|
# per-group allowlist from FLAGS declared in the pack config — DATA, never a
|
|
90
135
|
# core convention. The engine doesn't know "groceries_v2"/"b2b": the pack
|
data/lib/insika/packaging.rb
CHANGED
|
@@ -55,7 +55,10 @@ module Insika
|
|
|
55
55
|
|
|
56
56
|
# The never-ship set: the studio JS toolchain (only assets/dist ships) and
|
|
57
57
|
# the docs' own Jekyll Gemfile/config.
|
|
58
|
-
|
|
58
|
+
# docs/superpowers/ holds internal plans (customer names, live results): never
|
|
59
|
+
# tracked here any more, and never shipped even if one is committed by mistake.
|
|
60
|
+
NEVER_SHIP_PREFIXES = ["node_modules", "lib/insika/studio/test/", "lib/insika/studio/assets/src/",
|
|
61
|
+
"docs/superpowers/"].freeze
|
|
59
62
|
NEVER_SHIP_PATHS = %w[
|
|
60
63
|
docs/Gemfile docs/Gemfile.lock docs/_config.yml
|
|
61
64
|
lib/insika/studio/README.md lib/insika/studio/package.json
|
|
@@ -97,14 +100,20 @@ module Insika
|
|
|
97
100
|
# bracket classes collapsed), so the corpus data's `voc[êe]`-style
|
|
98
101
|
# patterns are caught by the same table.
|
|
99
102
|
def domain_content?(path)
|
|
100
|
-
|
|
101
|
-
fold(text).match?(PT_BR_TOKENS)
|
|
103
|
+
fold(payload_text(path)).match?(PT_BR_TOKENS)
|
|
102
104
|
end
|
|
103
105
|
|
|
104
106
|
# -> bool: does a payload file mention the demo persona name (`bia`)?
|
|
105
107
|
def persona_content?(path)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
payload_text(path).match?(PERSONA_NAME)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# -> String: the text the audit scans. Ruby goes through `ruby_text`;
|
|
112
|
+
# everything else is read as-is with invalid UTF-8 DROPPED — the payload
|
|
113
|
+
# also carries binary assets (the Studio's embedded webfonts), which hold
|
|
114
|
+
# no readable content but would otherwise raise on the first match.
|
|
115
|
+
def payload_text(path)
|
|
116
|
+
path.to_s.end_with?(".rb") ? ruby_text(path) : File.read(path).scrub("")
|
|
108
117
|
end
|
|
109
118
|
|
|
110
119
|
# The Ruby source as the audit reads it: heredoc bodies verbatim, string +
|
|
@@ -20,18 +20,30 @@ module Insika
|
|
|
20
20
|
|
|
21
21
|
STATUSES = %i[pending approved rejected].freeze
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
# Who resolves the action: the OPERATOR in the Studio (the turn suspended and
|
|
24
|
+
# waits) or the CUSTOMER on the next message (the turn ended with a question).
|
|
25
|
+
# Absent on a stored row = operator, so rows written before `kind` keep meaning.
|
|
26
|
+
OPERATOR = "operator"
|
|
27
|
+
CUSTOMER = "customer"
|
|
28
|
+
|
|
29
|
+
PendingAction = Data.define(:id, :task_id, :session_id, :turn, :tool, :args, :kind,
|
|
24
30
|
:status, :requested_at, :resolved_by, :resolved_at)
|
|
25
31
|
|
|
32
|
+
# One row per (session, tool) for a customer hold: a crash-and-re-execute of the
|
|
33
|
+
# holding turn rewrites the same row instead of leaving two open.
|
|
34
|
+
def self.confirmation_id(session_id, tool) = "confirm:#{session_id}:#{tool}"
|
|
35
|
+
|
|
26
36
|
def initialize(store:)
|
|
27
37
|
@store = store
|
|
28
38
|
end
|
|
29
39
|
|
|
30
40
|
# -> PendingAction (:pending). `args` is the Hash of the tool call's arguments.
|
|
31
|
-
def create(task_id:, turn:, tool:, args: {}, id: SecureRandom.uuid)
|
|
41
|
+
def create(task_id:, turn:, tool:, args: {}, id: SecureRandom.uuid, kind: OPERATOR, session_id: nil)
|
|
32
42
|
record = {
|
|
33
43
|
"id" => id.to_s,
|
|
34
44
|
"task_id" => task_id.to_s,
|
|
45
|
+
"session_id" => session_id&.to_s,
|
|
46
|
+
"kind" => kind.to_s,
|
|
35
47
|
"turn" => turn,
|
|
36
48
|
"tool" => tool.to_s,
|
|
37
49
|
"args" => deep_stringify(args),
|
|
@@ -52,29 +64,31 @@ module Insika
|
|
|
52
64
|
|
|
53
65
|
# -> [PendingAction] :pending for the task (recovery/UI). O(n) scan — single-node,
|
|
54
66
|
# like TaskStore#running_or_interrupted.
|
|
55
|
-
def open_for(task_id)
|
|
67
|
+
def open_for(task_id, kind: nil)
|
|
56
68
|
id = task_id.to_s
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
next if record.nil?
|
|
69
|
+
scan_open(kind).select { |pa| pa.task_id == id }
|
|
70
|
+
end
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
# -> [PendingAction] :pending for the session — the customer's holds live here,
|
|
73
|
+
# keyed by the conversation rather than the task that held them.
|
|
74
|
+
def open_for_session(session_id, kind: nil)
|
|
75
|
+
id = session_id.to_s
|
|
76
|
+
scan_open(kind).select { |pa| pa.session_id == id }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Customer holds of a session that an EARLIER task created and nothing
|
|
80
|
+
# resolved: the next message did not agree, so the engine rejects them.
|
|
81
|
+
# -> [PendingAction] the ones it expired.
|
|
82
|
+
def expire_customer_holds(session_id:, except_task_id:)
|
|
83
|
+
keep = except_task_id.to_s
|
|
84
|
+
open_for_session(session_id, kind: CUSTOMER).reject { |pa| pa.task_id == keep }
|
|
85
|
+
.map { |pa| resolve(pa.id, decision: :rejected, operator: "engine:expired") }
|
|
64
86
|
end
|
|
65
87
|
|
|
66
88
|
# -> [PendingAction] every :pending across all tasks — the approvals inbox
|
|
67
89
|
# (Studio). Single O(n) scan (vs. open_for per task = O(n·m)); the
|
|
68
90
|
# UI resolves task context afterwards via TaskStore#find.
|
|
69
|
-
def all_open
|
|
70
|
-
@store.list(SCOPE, KEY_PREFIX).filter_map do |key|
|
|
71
|
-
record = @store.get(SCOPE, key)
|
|
72
|
-
next if record.nil?
|
|
73
|
-
|
|
74
|
-
pa = to_pending(record)
|
|
75
|
-
pa if pa.status == :pending
|
|
76
|
-
end
|
|
77
|
-
end
|
|
91
|
+
def all_open(kind: nil) = scan_open(kind)
|
|
78
92
|
|
|
79
93
|
# -> resolved PendingAction. Only resolves :pending: a double resolution
|
|
80
94
|
# or an invalid decision -> ValidationError; absent -> NotFoundError.
|
|
@@ -101,13 +115,26 @@ module Insika
|
|
|
101
115
|
|
|
102
116
|
def key_for(id) = "#{KEY_PREFIX}#{id}"
|
|
103
117
|
|
|
118
|
+
def scan_open(kind)
|
|
119
|
+
wanted = kind&.to_s
|
|
120
|
+
@store.list(SCOPE, KEY_PREFIX).filter_map do |key|
|
|
121
|
+
record = @store.get(SCOPE, key)
|
|
122
|
+
next if record.nil?
|
|
123
|
+
|
|
124
|
+
pa = to_pending(record)
|
|
125
|
+
pa if pa.status == :pending && (wanted.nil? || pa.kind == wanted)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
104
129
|
def to_pending(record)
|
|
105
130
|
PendingAction.new(
|
|
106
131
|
id: record["id"],
|
|
107
132
|
task_id: record["task_id"],
|
|
133
|
+
session_id: record["session_id"],
|
|
108
134
|
turn: record["turn"],
|
|
109
135
|
tool: record["tool"],
|
|
110
136
|
args: record["args"],
|
|
137
|
+
kind: record["kind"] || OPERATOR,
|
|
111
138
|
status: record["status"].to_sym,
|
|
112
139
|
requested_at: record["requested_at"],
|
|
113
140
|
resolved_by: record["resolved_by"],
|
data/lib/insika/plugin/loader.rb
CHANGED
|
@@ -78,8 +78,9 @@ module Insika
|
|
|
78
78
|
@announced_roots.any? { |root| dir == root || dir.start_with?("#{root}#{File::SEPARATOR}") }
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
# One manifest per directory: insika.plugin.yml takes precedence over
|
|
81
|
+
# One manifest per directory: insika.plugin.yml takes precedence over a leftover
|
|
82
82
|
# plugin.yml (same dir). Order preserved (root precedence — first wins via `seen`).
|
|
83
|
+
# plugin.yml is still globbed only so load_manifest can refuse it out loud.
|
|
83
84
|
def manifest_files
|
|
84
85
|
selected = {}
|
|
85
86
|
@roots.each do |root|
|
|
@@ -96,7 +97,8 @@ module Insika
|
|
|
96
97
|
return nil unless manifest.is_a?(Hash)
|
|
97
98
|
|
|
98
99
|
if File.basename(file) == "plugin.yml"
|
|
99
|
-
warn "[plugin #{manifest['id']}] plugin.yml is
|
|
100
|
+
warn "[plugin #{manifest['id']}] plugin.yml was removed in 1.0 and is not loaded — rename to insika.plugin.yml"
|
|
101
|
+
return nil
|
|
100
102
|
end
|
|
101
103
|
manifest
|
|
102
104
|
rescue StandardError => e
|
|
@@ -9,47 +9,52 @@ module Insika
|
|
|
9
9
|
# (name + description + parameters.inspect, executor.rb:957). Outputs are
|
|
10
10
|
# PII-free digests: it never sees message text, only hashes leave this class.
|
|
11
11
|
# Pure stdlib (digest/sha2), no gem, no IO.
|
|
12
|
+
#
|
|
13
|
+
# The chain follows the cache boundary: the cumulative "prefix" is over the
|
|
14
|
+
# IDENTITY categories + tool_schemas only — the bytes the cache breakpoint
|
|
15
|
+
# actually covers. Volatile categories (memory, knowledge, briefing, request)
|
|
16
|
+
# render below the breakpoint, so a change there is not a prefix invalidation;
|
|
17
|
+
# they still get their own digest (the trace shows them) but sit AFTER the
|
|
18
|
+
# "prefix" key, outside the chain.
|
|
12
19
|
class PrefixFingerprint
|
|
13
20
|
# -> { "prompt" => "sha256…", …, "tool_schemas" => "sha256…",
|
|
14
|
-
# "prefix" => "sha256…" }
|
|
15
|
-
# Keys are the demodulized, downcased provider ids
|
|
21
|
+
# "prefix" => "sha256…", "memory" => "sha256…", … }
|
|
22
|
+
# Keys are the demodulized, downcased provider ids. Order: identity
|
|
23
|
+
# categories (render order), "tool_schemas", "prefix", then the volatile
|
|
24
|
+
# categories — everything before "prefix" is the chain.
|
|
16
25
|
# A category with no fragments is absent (not an empty hash).
|
|
17
26
|
# The category digest = SHA256 of the category's fragments joined "\n\n"
|
|
18
27
|
# (the Builder's separator — the digest matches rendered bytes).
|
|
19
|
-
# "prefix" = SHA256 of the
|
|
20
|
-
#
|
|
21
|
-
#
|
|
28
|
+
# "prefix" = SHA256 of the chain digests concatenated in order — any
|
|
29
|
+
# divergence anywhere above the boundary changes it. A fragment with no
|
|
30
|
+
# layer stamp reads as volatile, like everywhere else.
|
|
22
31
|
def self.compute(system_fragments, tool_serial:)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
grouped.each do |name, frags|
|
|
26
|
-
digests[name] = digest(frags.map(&:content).join("\n\n"))
|
|
27
|
-
end
|
|
32
|
+
identity, volatile = system_fragments.partition { |f| (f.layer || :volatile) == :identity }
|
|
33
|
+
digests = category_digests(identity)
|
|
28
34
|
digests["tool_schemas"] = digest(tool_serial.to_s) unless tool_serial.nil?
|
|
29
35
|
digests["prefix"] = digest(digests.values.join) unless digests.empty?
|
|
30
|
-
digests
|
|
36
|
+
digests.merge(category_digests(volatile))
|
|
31
37
|
end
|
|
32
38
|
|
|
33
|
-
# -> String | nil.
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# else
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
# The cumulative "prefix" key is deliberately SKIPPED in the scan: its
|
|
40
|
-
# digest changes whenever ANY category moves, so scanning it would shadow a
|
|
41
|
-
# vanished block (every surviving category matches, "prefix" differs, and
|
|
42
|
-
# the vanished-fallback below becomes unreachable — reporting `broke:
|
|
43
|
-
# prefix` instead of the category that actually left).
|
|
39
|
+
# -> String | nil. nil when the cumulative "prefix" did not move (a
|
|
40
|
+
# volatile change is not an invalidation). Else the first chain category
|
|
41
|
+
# (in CURRENT chain order) whose digest differs or is absent from
|
|
42
|
+
# `previous`; else the first PREVIOUS chain key now absent from the current
|
|
43
|
+
# chain (a vanished block is a divergence too). nil `previous` (first turn)
|
|
44
|
+
# -> nil. The returned name is a category id — PII-free by construction.
|
|
44
45
|
def self.invalidation_reason(current, previous)
|
|
45
46
|
return nil unless previous.is_a?(Hash)
|
|
47
|
+
return nil if current["prefix"] == previous["prefix"]
|
|
46
48
|
|
|
47
|
-
current.
|
|
48
|
-
|
|
49
|
+
chain(current).find { |name| previous[name] != current[name] } ||
|
|
50
|
+
(chain(previous) - current.keys).first
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.chain(map) = map.keys.take_while { |k| k != "prefix" }
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
def self.category_digests(fragments)
|
|
56
|
+
fragments.group_by { |f| category(f.source) }
|
|
57
|
+
.transform_values { |frags| digest(frags.map(&:content).join("\n\n")) }
|
|
53
58
|
end
|
|
54
59
|
|
|
55
60
|
def self.category(source) = source.to_s.split("::").last.to_s.downcase
|
|
@@ -98,23 +98,28 @@ module Insika
|
|
|
98
98
|
tools_allow_groups: h[:tools_allow_groups],
|
|
99
99
|
skills: h[:skills],
|
|
100
100
|
skills_eager: h[:skills_eager],
|
|
101
|
+
kits: h[:kits],
|
|
101
102
|
context_providers: h[:context_providers],
|
|
102
103
|
workflows_allow: h[:workflows_allow],
|
|
103
104
|
policies: Array(h[:policies]).map(&:to_sym),
|
|
104
105
|
prompt_refs: h[:prompt_refs] || [],
|
|
105
106
|
limits: symbolize_limits(h[:limits]),
|
|
106
107
|
approvals_required: h[:approvals_required],
|
|
108
|
+
customer_confirm: h[:customer_confirm],
|
|
107
109
|
capabilities: h[:capabilities],
|
|
108
110
|
# subagents: allowlist of child ids; build re-normalizes to
|
|
109
111
|
# [String]. nil round-trips as nil (opt-in: NONE).
|
|
110
112
|
subagents: h[:subagents],
|
|
111
113
|
tools_deferred: h[:tools_deferred],
|
|
112
114
|
memory: h[:memory],
|
|
115
|
+
memory_retrieval: h[:memory_retrieval],
|
|
116
|
+
shared_conversations: h[:shared_conversations],
|
|
113
117
|
prompt_caching: h[:prompt_caching],
|
|
114
118
|
# tool_persistence: the one opt-out flag — nil round-trips as nil (= ON);
|
|
115
119
|
# only a stored explicit false turns the discipline block off.
|
|
116
120
|
tool_persistence: h[:tool_persistence],
|
|
117
121
|
tool_output_compression: h[:tool_output_compression],
|
|
122
|
+
fencing: h[:fencing],
|
|
118
123
|
# params/model_policy: the resolver tolerates string keys from
|
|
119
124
|
# the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
|
|
120
125
|
# re-symbolization needed here.
|
|
@@ -7,11 +7,12 @@ module Insika
|
|
|
7
7
|
# poisoned credential; a fatal is retried only after the operator fixes the
|
|
8
8
|
# cause):
|
|
9
9
|
#
|
|
10
|
-
# :fatal 401/402/403/400 (auth, billing,
|
|
11
|
-
#
|
|
10
|
+
# :fatal 401/402/403/400 (auth, billing, bad request,
|
|
11
|
+
# context too long) — retrying does not help.
|
|
12
12
|
# :retryable 5xx/529/socket/timeout — the same call may succeed
|
|
13
13
|
# moments later.
|
|
14
|
-
# :rate_limited_short a 429 that says "back off briefly" (RPM-scale)
|
|
14
|
+
# :rate_limited_short a 429 that says "back off briefly" (RPM-scale), or a
|
|
15
|
+
# 400 quota message RubyLLM raises as RateLimitError.
|
|
15
16
|
# :rate_limited_long a 429 with a long retry-after — quota-scale.
|
|
16
17
|
#
|
|
17
18
|
# The classification is STRING-based (class names, no constant references):
|
|
@@ -98,6 +99,20 @@ module Insika
|
|
|
98
99
|
(names & RUBY_LLM_ERROR_NAMES).any? || transport?(names)
|
|
99
100
|
end
|
|
100
101
|
|
|
102
|
+
# :fatal for THIS node only: the account cannot pay (402), the key is wrong
|
|
103
|
+
# or lacks access (401/403), or the model does not exist there (404).
|
|
104
|
+
# Retrying the node never helps, but another node — another account,
|
|
105
|
+
# another model — may answer, so Reliability rotates instead of killing the
|
|
106
|
+
# turn. A 400 stays out: a malformed request fails on any node.
|
|
107
|
+
NODE_FATAL_ERROR_NAMES = %w[
|
|
108
|
+
RubyLLM::UnauthorizedError RubyLLM::PaymentRequiredError RubyLLM::ForbiddenError
|
|
109
|
+
].freeze
|
|
110
|
+
NODE_FATAL_STATUSES = [401, 402, 403, 404].freeze
|
|
111
|
+
|
|
112
|
+
def node_fatal?(error)
|
|
113
|
+
(class_names(error) & NODE_FATAL_ERROR_NAMES).any? || NODE_FATAL_STATUSES.include?(http_status(error))
|
|
114
|
+
end
|
|
115
|
+
|
|
101
116
|
# The typed ProviderError the executor stores and emits.
|
|
102
117
|
def wrap(error)
|
|
103
118
|
c = classify(error)
|
data/lib/insika/queue_policy.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Insika
|
|
|
24
24
|
#
|
|
25
25
|
# session vars["queue_mode"] — one conversation pinned by an operator
|
|
26
26
|
# profile.limits[:<key>] — per-agent (a PRESENT key wins, incl. nil/0 = off)
|
|
27
|
-
# settings["queue"][<key>] — platform default
|
|
27
|
+
# settings["queue"][<key>] — platform default (Studio > Settings > Burst)
|
|
28
28
|
# DEFAULTS[<key>] — = today's behavior
|
|
29
29
|
#
|
|
30
30
|
# Every default is off: a bare wiring behaves exactly as it did before this
|
|
@@ -126,9 +126,14 @@ module Insika
|
|
|
126
126
|
# "inherit the platform default". Same semantics EdgeLimiter documents at
|
|
127
127
|
# `edge_limiter.rb:57`, and for the same reason: an imported pack carrying an
|
|
128
128
|
# explicit null must not silently re-enable a platform behavior.
|
|
129
|
+
#
|
|
130
|
+
# The PLATFORM layer reads nil the other way round: its form (Studio > Settings >
|
|
131
|
+
# Burst) writes nil for every blank field, and blank there means "no platform
|
|
132
|
+
# default", not 0 — a blank `steer_max_messages` must not become "never steer"
|
|
133
|
+
# for the whole deploy. An agent still says "off" with an explicit 0.
|
|
129
134
|
def self.pick(key, limits, platform)
|
|
130
135
|
return limits[key].to_i if limits.key?(key)
|
|
131
|
-
return platform[key.to_s].to_i
|
|
136
|
+
return platform[key.to_s].to_i unless platform[key.to_s].nil?
|
|
132
137
|
|
|
133
138
|
DEFAULTS[key]
|
|
134
139
|
end
|
|
@@ -38,11 +38,32 @@ module Insika
|
|
|
38
38
|
|
|
39
39
|
MAX_FINDINGS = 10
|
|
40
40
|
|
|
41
|
+
RESPONSE_SCHEMA = {
|
|
42
|
+
"type" => "object",
|
|
43
|
+
"properties" => {
|
|
44
|
+
"rationale" => { "type" => "string" },
|
|
45
|
+
"edits" => {
|
|
46
|
+
"type" => "array",
|
|
47
|
+
"items" => {
|
|
48
|
+
"type" => "object",
|
|
49
|
+
"properties" => {
|
|
50
|
+
"file" => { "type" => "string" }, "op" => { "type" => "string", "enum" => %w[replace append] },
|
|
51
|
+
"anchor" => { "type" => "string" }, "before" => { "type" => "string" },
|
|
52
|
+
"after" => { "type" => "string" },
|
|
53
|
+
"addresses" => { "type" => "array", "items" => { "type" => "string" } }
|
|
54
|
+
},
|
|
55
|
+
"required" => %w[file op after]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"required" => ["edits"]
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
41
62
|
# The model ref, so a panel can name WHICH proposer failed without guessing.
|
|
42
63
|
attr_reader :model
|
|
43
64
|
|
|
44
65
|
# ask: ->(prompt) { "<raw model text>" }, or something answering `#content`
|
|
45
|
-
# plus
|
|
66
|
+
# plus RubyLLM-compatible `#tokens`. The second
|
|
46
67
|
# shape is what lets the panel's budget count what a proposal cost; a
|
|
47
68
|
# plain String stays valid and simply reports no cost, which is what
|
|
48
69
|
# every existing caller and every fake does.
|
|
@@ -63,7 +84,7 @@ module Insika
|
|
|
63
84
|
raise Unusable, "no writable file has any content to anchor an edit in" if files.empty?
|
|
64
85
|
|
|
65
86
|
answer = @ask.call(build_prompt(agent_id, Array(findings).first(MAX_FINDINGS), files, limits))
|
|
66
|
-
parsed = parse(
|
|
87
|
+
parsed = parse(answer)
|
|
67
88
|
parsed["proposer"] = @model
|
|
68
89
|
parsed["tokens"] = tokens_of(answer)
|
|
69
90
|
parsed["cached"] = cached_of(answer)
|
|
@@ -80,29 +101,33 @@ module Insika
|
|
|
80
101
|
# reason `Evals::Runner#billed_tokens` includes it: a ceiling that cannot see
|
|
81
102
|
# what the cache served is not a ceiling on what was sent.
|
|
82
103
|
def tokens_of(answer)
|
|
83
|
-
return nil unless answer.respond_to?(:
|
|
104
|
+
return nil unless answer.respond_to?(:tokens)
|
|
84
105
|
|
|
85
|
-
|
|
106
|
+
usage = answer.tokens
|
|
107
|
+
total = usage.input.to_i + usage.output.to_i + usage.cache_read.to_i + usage.cache_write.to_i
|
|
86
108
|
total.positive? ? total : nil
|
|
87
109
|
end
|
|
88
110
|
|
|
89
111
|
def cached_of(answer)
|
|
90
|
-
return nil unless answer.respond_to?(:
|
|
112
|
+
return nil unless answer.respond_to?(:tokens)
|
|
91
113
|
|
|
92
|
-
cached = answer.
|
|
114
|
+
cached = answer.tokens.cache_read.to_i
|
|
93
115
|
cached.positive? ? cached : nil
|
|
94
116
|
end
|
|
95
117
|
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
118
|
+
# Native responses own JSON parsing. Only injected text asks retain the
|
|
119
|
+
# existing prose/fence extraction contract.
|
|
120
|
+
def parse(answer)
|
|
121
|
+
if answer.respond_to?(:parsed)
|
|
122
|
+
parsed = answer.parsed
|
|
123
|
+
else
|
|
124
|
+
body = text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
|
|
125
|
+
first = body.index("{")
|
|
126
|
+
last = body.rindex("}")
|
|
127
|
+
raise Unusable, "the proposer answered with no JSON object" if first.nil? || last.nil? || last < first
|
|
128
|
+
|
|
129
|
+
parsed = JSON.parse(body[first..last])
|
|
130
|
+
end
|
|
106
131
|
raise Unusable, "the proposer's JSON is not an object" unless parsed.is_a?(Hash)
|
|
107
132
|
raise Unusable, "the proposer's JSON carries no `edits`" unless parsed["edits"].is_a?(Array)
|
|
108
133
|
|
|
@@ -254,7 +279,7 @@ module Insika
|
|
|
254
279
|
llm ||= RubyLLM
|
|
255
280
|
lambda do |prompt|
|
|
256
281
|
llm.chat(model: model, provider: provider, assume_model_exists: true)
|
|
257
|
-
.with_temperature(0).ask(prompt)
|
|
282
|
+
.with_temperature(0).with_schema("schema" => Proposer::RESPONSE_SCHEMA, "strict" => false).ask(prompt)
|
|
258
283
|
end
|
|
259
284
|
end
|
|
260
285
|
end
|
data/lib/insika/reliability.rb
CHANGED
|
@@ -6,8 +6,9 @@ module Insika
|
|
|
6
6
|
# the circuit breaker — all as DATA on `AgentProfile#reliability`, never a
|
|
7
7
|
# parallel code path. The failure classification is B9's
|
|
8
8
|
# (ProviderErrorClassifier): ONLY :retryable / :rate_limited_* ever retry or
|
|
9
|
-
# rotate; a :fatal is re-raised immediately (a
|
|
10
|
-
#
|
|
9
|
+
# rotate; a :fatal is re-raised immediately (a malformed request fails on any
|
|
10
|
+
# model) — unless it is fatal only for that node (402/401/403/404:
|
|
11
|
+
# `ProviderErrorClassifier.node_fatal?`), which skips to the next node.
|
|
11
12
|
#
|
|
12
13
|
# Each ATTEMPT is a fresh chat built by the caller (the coordinator yields the
|
|
13
14
|
# selection): a failed `ask` leaves its user message in the chat, so re-asking
|
|
@@ -100,7 +101,15 @@ module Insika
|
|
|
100
101
|
# never rotated).
|
|
101
102
|
retryable = retryable_failure?(e)
|
|
102
103
|
raise unless retryable
|
|
103
|
-
|
|
104
|
+
if kind_of(e) == :fatal && !e.is_a?(Insika::TimeoutError)
|
|
105
|
+
# Fatal for THIS node only (no balance, bad key, unknown model):
|
|
106
|
+
# skip to the next node at once — no retry, no backoff, no breaker
|
|
107
|
+
# bump (an open primary circuit fails fast WITHOUT rotating).
|
|
108
|
+
raise unless ProviderErrorClassifier.node_fatal?(e) && node != nodes.last
|
|
109
|
+
|
|
110
|
+
record_failure(tenant, selection, nil, e, agent)
|
|
111
|
+
break
|
|
112
|
+
end
|
|
104
113
|
|
|
105
114
|
record_failure(tenant, selection, breaker, e, agent)
|
|
106
115
|
# the last attempt of the last node re-raises; otherwise back off
|
data/lib/insika/router/app.rb
CHANGED
|
@@ -49,7 +49,7 @@ module Insika
|
|
|
49
49
|
return health_response if req.request_method == "GET" && segments == ["up"]
|
|
50
50
|
|
|
51
51
|
raw_body = read_body(env)
|
|
52
|
-
backend = pick_backend(req
|
|
52
|
+
backend = pick_backend(req, segments, raw_body)
|
|
53
53
|
return unavailable_response if backend.nil?
|
|
54
54
|
|
|
55
55
|
proxy(req, backend, raw_body)
|
|
@@ -57,18 +57,20 @@ module Insika
|
|
|
57
57
|
|
|
58
58
|
private
|
|
59
59
|
|
|
60
|
-
def pick_backend(
|
|
60
|
+
def pick_backend(req, segments, raw_body)
|
|
61
61
|
backends = @pool.backends
|
|
62
62
|
return nil if backends.empty?
|
|
63
63
|
|
|
64
|
-
key = session_key(
|
|
64
|
+
key = session_key(req, segments, raw_body)
|
|
65
65
|
return @pool.ring.backend_for(key) if key
|
|
66
66
|
|
|
67
67
|
@rr_index = (@rr_index + 1) % backends.size
|
|
68
68
|
backends[@rr_index]
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
def session_key(
|
|
71
|
+
def session_key(req, segments, raw_body)
|
|
72
|
+
method = req.request_method
|
|
73
|
+
return SessionKey.extract(method, segments, body: -> {}, query: req.GET) if method == "GET"
|
|
72
74
|
return nil if raw_body.nil?
|
|
73
75
|
|
|
74
76
|
if raw_body.bytesize > @body_max_bytes
|
|
@@ -77,7 +79,14 @@ module Insika
|
|
|
77
79
|
return nil
|
|
78
80
|
end
|
|
79
81
|
|
|
80
|
-
SessionKey.extract(method, segments, body: -> {
|
|
82
|
+
SessionKey.extract(method, segments, body: -> { parse_body(req, raw_body) })
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# JSON for the machine routes; the Studio's forms post urlencoded.
|
|
86
|
+
def parse_body(req, raw_body)
|
|
87
|
+
return Rack::Utils.parse_query(raw_body) if req.media_type == "application/x-www-form-urlencoded"
|
|
88
|
+
|
|
89
|
+
JSON.parse(raw_body)
|
|
81
90
|
end
|
|
82
91
|
|
|
83
92
|
# Reads the WHOLE body (it must be forwarded intact) — `body_max_bytes`
|