insika 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -3
- data/README.md +1 -1
- data/bin/insika +22 -7
- data/docs/AGENTS.md +129 -5
- data/docs/CHANNELS.md +1 -1
- data/docs/CONTEXT.md +22 -5
- data/docs/DEPLOY.md +30 -10
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +1 -1
- data/docs/LOADTEST.md +3 -2
- data/docs/OBSERVABILITY.md +11 -2
- data/docs/REFINEMENT.md +6 -6
- data/docs/RELEASING.md +7 -7
- data/docs/RUNNING-LOCAL.md +1 -1
- data/docs/SECURITY.md +24 -11
- data/docs/SKILLS.md +189 -3
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/index.md +1 -1
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +89 -22
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/channel_delivery.rb +14 -11
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +3 -3
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +7 -7
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +62 -13
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +5 -3
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +9 -9
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/context/builder.rb +2 -2
- data/lib/insika/context/fragment.rb +27 -3
- data/lib/insika/context/priority.rb +3 -2
- data/lib/insika/context/providers/memory.rb +1 -1
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +5 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/doctor.rb +250 -5
- data/lib/insika/dsl/runtime.rb +12 -9
- data/lib/insika/dsl/server_boot.rb +4 -3
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +72 -15
- data/lib/insika/edge_limiter.rb +144 -6
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +13 -10
- data/lib/insika/errors.rb +61 -5
- data/lib/insika/evals/assertions.rb +12 -12
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +3 -3
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/executor.rb +423 -108
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/golden_store.rb +2 -2
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/message_origin.rb +2 -2
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/onboarding.rb +2 -2
- data/lib/insika/outbox_store.rb +2 -2
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/profile_source.rb +12 -6
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +2 -2
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +9 -9
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +2 -2
- data/lib/insika/safety/detectors.rb +5 -5
- data/lib/insika/safety/factory.rb +3 -3
- data/lib/insika/safety/input_guardrail.rb +19 -4
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +2 -2
- data/lib/insika/safety/output_validator.rb +2 -2
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/server/app.rb +223 -51
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +15 -7
- data/lib/insika/server/responses.rb +18 -8
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +3 -3
- data/lib/insika/session_store.rb +1 -1
- data/lib/insika/settings_store.rb +5 -5
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +127 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +1 -1
- data/lib/insika/studio/app.rb +183 -61
- data/lib/insika/studio/assets/dist/application.js +25 -24
- data/lib/insika/studio/forms.rb +13 -18
- data/lib/insika/studio/nav_icons.rb +1 -1
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +2 -2
- data/lib/insika/studio/views/agents.erb +1 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +78 -3
- data/lib/insika/studio/views/settings.erb +7 -12
- data/lib/insika/studio/views/skills.erb +67 -12
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +1 -1
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +27 -27
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +8 -8
- data/lib/insika/tool_envelope.rb +1 -1
- data/lib/insika/tool_manifest.rb +6 -6
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +4 -5
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +17 -13
- data/lib/insika/turn_timing.rb +2 -2
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +77 -26
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +32 -15
- metadata +19 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Level-2 skill BODIES in the prompt, with two selection modes — both
|
|
7
|
+
# deterministic, neither asks the model:
|
|
8
|
+
#
|
|
9
|
+
# profile.skills_eager -> the agent's eager set (all, or a named list),
|
|
10
|
+
# every turn. No decision at all, so no miss rate.
|
|
11
|
+
# Costs the bodies' tokens.
|
|
12
|
+
# `triggers:` in the -> only the skills whose trigger matches this
|
|
13
|
+
# frontmatter message (whole word, accent- and case-insensitive).
|
|
14
|
+
#
|
|
15
|
+
# Model-invoked loading (load_skill) stays the fallback for everything else.
|
|
16
|
+
#
|
|
17
|
+
# A trigger only belongs on a skill that can COMPLETE the turn by itself.
|
|
18
|
+
# Injecting a reference table whose companion skill holds the procedure is
|
|
19
|
+
# worse than injecting nothing: the model has a plausible half-recipe in the
|
|
20
|
+
# prompt, so it never calls load_skill for the other half. Measured on a real
|
|
21
|
+
# pack — the line map arrived, the query-construction rules did not, and the
|
|
22
|
+
# searches came out malformed.
|
|
23
|
+
# Activation here is NOT a tool call, so nothing in the transcript would show
|
|
24
|
+
# it. The fragment's `labels` carry the name AND THE REASON, and the EXECUTOR
|
|
25
|
+
# announces them (:skill_activated) — not this provider. A provider only has the
|
|
26
|
+
# ContextRequest, which has no task, and the Studio's SSE drops an event whose
|
|
27
|
+
# meta lacks `task_id` when the subscriber is task-scoped (Subscription#matches?):
|
|
28
|
+
# emitting from here produced an event that was correct and never arrived.
|
|
29
|
+
class SkillTrigger < ContextProvider
|
|
30
|
+
def initialize(catalog:)
|
|
31
|
+
@catalog = catalog
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(request)
|
|
35
|
+
matched = select(request)
|
|
36
|
+
return [] if matched.empty?
|
|
37
|
+
|
|
38
|
+
content = matched.map do |skill, _reason|
|
|
39
|
+
%(<active_skill name="#{skill.name}">\n#{skill.body}\n</active_skill>)
|
|
40
|
+
end.join("\n\n")
|
|
41
|
+
|
|
42
|
+
labels = matched.map { |skill, reason| { "name" => skill.name, "reason" => reason } }
|
|
43
|
+
[ContextFragment.build(content: content, placement: :system,
|
|
44
|
+
priority: Context::Priority::SKILL_BODY, source: id,
|
|
45
|
+
labels: labels)]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# -> [[skill, reason]]. Two independent reasons a body lands here: the AGENT
|
|
51
|
+
# marked it eager (always), or a `triggers:` entry matched THIS message.
|
|
52
|
+
# The union is injected; everything else stays at level 1 for load_skill, where
|
|
53
|
+
# the model's call is the only record of what it reached for.
|
|
54
|
+
#
|
|
55
|
+
# The reason travels with the skill from here to the activation card, because
|
|
56
|
+
# "which skills were active" without "why each one was" is the information the
|
|
57
|
+
# deterministic paths destroyed when they replaced the load_skill call.
|
|
58
|
+
def select(request)
|
|
59
|
+
eager = @catalog.eager_for(request.profile).map { |skill| [skill, "eager"] }
|
|
60
|
+
# `triggered` reads the LAZY set, which excludes the eager one — so a skill
|
|
61
|
+
# cannot arrive twice. uniq_by name anyway: the invariant is worth not
|
|
62
|
+
# depending on from here.
|
|
63
|
+
selected = (eager + triggered(request)).uniq { |skill, _reason| skill.name }
|
|
64
|
+
(selected + companions(selected, request)).uniq { |skill, _reason| skill.name }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Declared `companions:` travel with whatever brought them, so the half-recipe
|
|
68
|
+
# state cannot be assembled: the line map that arrived by trigger takes its
|
|
69
|
+
# query-construction rules with it. ONE LEVEL, deliberately — a transitive walk
|
|
70
|
+
# would make a cycle a hang and a chain a budget blowout, and "cannot work
|
|
71
|
+
# without" is a direct relationship.
|
|
72
|
+
#
|
|
73
|
+
# Restricted to the agent's own allowed set: a companion the agent cannot see
|
|
74
|
+
# is not injectable, and `doctor` flags that declaration rather than the engine
|
|
75
|
+
# quietly widening the allowlist.
|
|
76
|
+
def companions(selected, request)
|
|
77
|
+
wanted = selected.flat_map { |skill, _reason| Array(skill.companions).map { |c| [c.to_s, skill.name] } }
|
|
78
|
+
return [] if wanted.empty?
|
|
79
|
+
|
|
80
|
+
by_name = @catalog.effective(request.profile.skills, agent: request.profile.id)
|
|
81
|
+
.each_with_object({}) { |s, acc| acc[s.name] = s }
|
|
82
|
+
wanted.filter_map do |name, of|
|
|
83
|
+
skill = by_name[name]
|
|
84
|
+
[skill, "companion:#{of}"] if skill
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def triggered(request)
|
|
89
|
+
message = fold(request.message)
|
|
90
|
+
return [] if message.empty?
|
|
91
|
+
|
|
92
|
+
@catalog.lazy_for(request.profile).filter_map do |skill|
|
|
93
|
+
phrase = matched_trigger(skill, message)
|
|
94
|
+
[skill, "trigger:#{phrase}"] if phrase
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The trigger phrase that fired, or nil. AS AUTHORED, never as typed: the
|
|
99
|
+
# reason lands on the activation card, and what an operator needs there is the
|
|
100
|
+
# config line they can go and edit — not an echo of the customer's message
|
|
101
|
+
# (which also keeps the label content-free, like the rest of the trace).
|
|
102
|
+
#
|
|
103
|
+
# Two hygiene rules, and both are tokenization rather than the semantic
|
|
104
|
+
# matching this feature deliberately does not do:
|
|
105
|
+
#
|
|
106
|
+
# whole word — bare substring made `triggers: presente` fire inside
|
|
107
|
+
# *apresente*, and the card now PRINTS the matched phrase: `trigger:presente`
|
|
108
|
+
# on a turn about *apresentação* would discredit the card on day one.
|
|
109
|
+
#
|
|
110
|
+
# folded accents — the corpus is Portuguese and customers type *maquiagem*
|
|
111
|
+
# and *maquiágem* unpredictably, so both sides are folded before comparing.
|
|
112
|
+
def matched_trigger(skill, folded_message)
|
|
113
|
+
skill.triggers.find do |trigger|
|
|
114
|
+
needle = fold(trigger)
|
|
115
|
+
next false if needle.empty?
|
|
116
|
+
|
|
117
|
+
/(?<![[:alnum:]])#{Regexp.escape(needle)}(?![[:alnum:]])/.match?(folded_message)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# NFD splits an accented letter into letter + combining mark; dropping the
|
|
122
|
+
# marks (\p{Mn}) leaves the bare letter, so "maquiágem" and "maquiagem" fold
|
|
123
|
+
# to the same string.
|
|
124
|
+
def fold(text) = text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Per-session CONTEXT trace, for the Studio's breakdown-by-category card
|
|
5
|
+
# One record per session in the raw backend (scope
|
|
6
|
+
# "context_traces") — RUNTIME data, next to sessions/tasks, like the
|
|
7
|
+
# ToolTraceStore it mirrors. A capped LIST of entries, ONE PER TURN, written
|
|
8
|
+
# by the Executor after tool assembly.
|
|
9
|
+
#
|
|
10
|
+
# Unlike the tool trace there is NO security machinery here by construction:
|
|
11
|
+
# the entry is counts and provider ids only (category -> tokens/fragments/
|
|
12
|
+
# pinned, the tools estimate, the budget verdict) — never fragment content,
|
|
13
|
+
# so there is nothing to mask. `record` still rescues everything: the trace
|
|
14
|
+
# NEVER breaks the turn.
|
|
15
|
+
class ContextTraceStore
|
|
16
|
+
SCOPE = "context_traces"
|
|
17
|
+
MAX_PER_SESSION = 50 # one per turn; turns are the unit, not tool calls
|
|
18
|
+
|
|
19
|
+
def initialize(store:)
|
|
20
|
+
@store = store
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Writes an entry into the session — UPSERT by (task_id, turn): a turn that
|
|
24
|
+
# suspends (approval) and resumes re-runs the context stage, and the
|
|
25
|
+
# re-record replaces the first one instead of duplicating it. (`turn` is
|
|
26
|
+
# 1-based PER TASK, so the task is part of the key.) Missing session_id ->
|
|
27
|
+
# no-op.
|
|
28
|
+
def record(session_id:, entry:)
|
|
29
|
+
sid = session_id.to_s
|
|
30
|
+
return if sid.empty?
|
|
31
|
+
|
|
32
|
+
e = sanitize(entry)
|
|
33
|
+
key = [e["task_id"], e["turn"]]
|
|
34
|
+
list = (@store.get(SCOPE, sid) || []).reject { |x| [x["task_id"], x["turn"]] == key } + [e]
|
|
35
|
+
@store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
|
|
36
|
+
rescue StandardError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# -> [Hash] session entries in chronological order. [] if none.
|
|
41
|
+
def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
|
|
42
|
+
|
|
43
|
+
# Discards a session's trace (cleanup). -> bool (did it exist?).
|
|
44
|
+
def clear(session_id) = @store.delete(SCOPE, session_id.to_s)
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Keeps only the known shape; coerces numbers and strings so a caller bug
|
|
49
|
+
# degrades the card instead of poisoning the record.
|
|
50
|
+
def sanitize(entry)
|
|
51
|
+
e = entry.is_a?(Hash) ? entry : {}
|
|
52
|
+
categories = e[:categories] || e["categories"] || {}
|
|
53
|
+
tools = e[:tools] || e["tools"] || {}
|
|
54
|
+
{
|
|
55
|
+
"task_id" => (e[:task_id] || e["task_id"]).to_s,
|
|
56
|
+
"turn" => int(e[:turn] || e["turn"]),
|
|
57
|
+
"at" => (e[:at] || e["at"]).to_s,
|
|
58
|
+
"cap" => int(e[:cap] || e["cap"]),
|
|
59
|
+
"used" => int(e[:used] || e["used"]),
|
|
60
|
+
"evicted" => Array(e[:evicted] || e["evicted"]).map(&:to_s),
|
|
61
|
+
"categories" => categories.each_with_object({}) do |(name, c), acc|
|
|
62
|
+
c = {} unless c.is_a?(Hash)
|
|
63
|
+
cat = { "tokens" => int(c[:tokens] || c["tokens"]),
|
|
64
|
+
"fragments" => int(c[:fragments] || c["fragments"]),
|
|
65
|
+
"pinned" => int(c[:pinned] || c["pinned"]) }
|
|
66
|
+
# WHAT the category carried and WHY ({name, reason}) — still ids only, so
|
|
67
|
+
# the no-masking-needed contract above holds. Omitted when empty: most
|
|
68
|
+
# categories have nothing to name and an empty key is just noise.
|
|
69
|
+
labels = normalize_labels(c[:labels] || c["labels"])
|
|
70
|
+
cat["labels"] = labels unless labels.empty?
|
|
71
|
+
acc[name.to_s] = cat
|
|
72
|
+
end,
|
|
73
|
+
"tools" => { "count" => int(tools[:count] || tools["count"]),
|
|
74
|
+
"tokens" => int(tools[:tokens] || tools["tokens"]) }
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Labels are {name, reason} in string keys (ContextFragment.label). A bare string
|
|
79
|
+
# still reads as a nameless-reason label: an entry recorded before reasons existed,
|
|
80
|
+
# or a caller that only has the id, degrades the card instead of poisoning it.
|
|
81
|
+
def normalize_labels(raw)
|
|
82
|
+
Array(raw).filter_map do |label|
|
|
83
|
+
name, reason = label.is_a?(Hash) ? [label[:name] || label["name"], label[:reason] || label["reason"]] : [label, nil]
|
|
84
|
+
next if name.to_s.empty?
|
|
85
|
+
|
|
86
|
+
{ "name" => name.to_s, "reason" => reason&.to_s }.compact
|
|
87
|
+
end.uniq
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def int(value) = Integer(value || 0)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -4,8 +4,8 @@ require "securerandom"
|
|
|
4
4
|
require "time"
|
|
5
5
|
|
|
6
6
|
module Insika
|
|
7
|
-
# Durable record of an ASYNC delegation (
|
|
8
|
-
# "delegation durability"). The synchronous subagent
|
|
7
|
+
# Durable record of an ASYNC delegation (hermes
|
|
8
|
+
# "delegation durability"). The synchronous subagent needs no
|
|
9
9
|
# record — it lives and dies inside the parent's turn. The ASYNC subagent does:
|
|
10
10
|
# the parent DISPATCHES and its turn ends; the child runs independently; when the
|
|
11
11
|
# child finishes, its result is delivered to the parent as a NEW turn (never
|
data/lib/insika/doctor.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
# `insika doctor` (
|
|
4
|
+
# `insika doctor` (— OpenClaw's "strict config + doctor --fix",
|
|
5
5
|
# "the repo's best productization discipline"). A read-only diagnosis of a
|
|
6
6
|
# deployment's configuration that turns the scattered, silent boot warnings into
|
|
7
7
|
# ONE structured report — and, with `--fix`, applies the safe autofixes.
|
|
@@ -58,12 +58,16 @@ module Insika
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def initialize(env: ENV, settings_store: nil, llm_provider_store: nil, tool_store: nil,
|
|
61
|
-
agent_file_store: nil,
|
|
61
|
+
agent_file_store: nil, skill_store: nil, skill_catalog: nil,
|
|
62
|
+
profile_source: nil, backend: nil, extra_env_specs: [])
|
|
62
63
|
@env = env
|
|
63
64
|
@settings_store = settings_store
|
|
64
65
|
@llm_provider_store = llm_provider_store
|
|
65
66
|
@tool_store = tool_store
|
|
66
67
|
@agent_file_store = agent_file_store
|
|
68
|
+
@skill_store = skill_store
|
|
69
|
+
@skill_catalog = skill_catalog
|
|
70
|
+
@profile_source = profile_source
|
|
67
71
|
@backend = backend
|
|
68
72
|
@extra_env_specs = extra_env_specs
|
|
69
73
|
end
|
|
@@ -84,7 +88,7 @@ module Insika
|
|
|
84
88
|
|
|
85
89
|
def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
|
|
86
90
|
check_admin_token check_data_tools check_prompt_files check_relay_channel
|
|
87
|
-
check_web_widget]
|
|
91
|
+
check_web_widget check_skill_eager check_skill_drift]
|
|
88
92
|
|
|
89
93
|
def safe(check)
|
|
90
94
|
Array(send(check))
|
|
@@ -155,7 +159,7 @@ module Insika
|
|
|
155
159
|
message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
|
|
156
160
|
end
|
|
157
161
|
|
|
158
|
-
# A half-configured relay is the silent kind of broken
|
|
162
|
+
# A half-configured relay is the silent kind of broken: with only
|
|
159
163
|
# the deliver URL set, nothing is mounted and every inbound POST 404s; with only
|
|
160
164
|
# the token, the engine accepts turns it can never answer and the customer waits
|
|
161
165
|
# forever on a reply that is sitting in the outbox. Both halves or neither.
|
|
@@ -176,7 +180,7 @@ module Insika
|
|
|
176
180
|
end
|
|
177
181
|
|
|
178
182
|
# The widget is the one PUBLIC channel, so its misconfigurations are the ones that
|
|
179
|
-
# cost money rather than just failing
|
|
183
|
+
# cost money rather than just failing. Three of them, in the
|
|
180
184
|
# order they bite: half the switch set (mounted nowhere, or mounted addressing
|
|
181
185
|
# nothing), and a mount with no chat rate limit anywhere — which the channel
|
|
182
186
|
# refuses with a 503 rather than opening, but which reads to an operator as "the
|
|
@@ -202,6 +206,247 @@ module Insika
|
|
|
202
206
|
"INSIKA_WIDGET_AGENTS sets limits.chat_rate_limit (a public channel with no ceiling is not served)", fix: nil)]
|
|
203
207
|
end
|
|
204
208
|
|
|
209
|
+
# Eagerness moved from the SKILL.md frontmatter to `profile.skills_eager`, because
|
|
210
|
+
# skills are SHARED and a flag on the skill forced one decision onto every agent
|
|
211
|
+
# holding it. The parser now ignores `eager:` — which is the quiet kind of upgrade:
|
|
212
|
+
# nothing crashes, the body simply stops being in the prompt. This check is the
|
|
213
|
+
# only thing that says so. Its sibling half is the opposite mistake: a name in
|
|
214
|
+
# `skills_eager` that the agent's `skills` allowlist does not contain is
|
|
215
|
+
# intersected away at runtime, so the operator's intent evaporates in silence.
|
|
216
|
+
def check_skill_eager
|
|
217
|
+
findings = stale_eager_frontmatter + unreachable_eager_names
|
|
218
|
+
return findings if findings.any?
|
|
219
|
+
return [] unless @skill_store || @skill_catalog || @profile_source
|
|
220
|
+
|
|
221
|
+
[ok("skill-eager", "skill eagerness: per-agent (profile.skills_eager), no stale frontmatter")]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def stale_eager_frontmatter
|
|
225
|
+
skill_sources.filter_map do |name, content|
|
|
226
|
+
next unless frontmatter_of(content).key?("eager")
|
|
227
|
+
|
|
228
|
+
Finding.new(check: "skill-eager", severity: :warn, fix: nil,
|
|
229
|
+
message: "skill '#{name}' still declares `eager:` in its frontmatter — the key is IGNORED. " \
|
|
230
|
+
"Eagerness is per-agent now: put the name in that agent's `skills_eager` " \
|
|
231
|
+
"(Studio > Skills, or `skills_eager \"#{name}\"` in the DSL).")
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def unreachable_eager_names
|
|
236
|
+
return [] unless @profile_source
|
|
237
|
+
|
|
238
|
+
@profile_source.all.flat_map do |profile|
|
|
239
|
+
eager = profile.skills_eager
|
|
240
|
+
next [] unless eager.is_a?(Array)
|
|
241
|
+
next [] if profile.skills.nil? # nil = every skill allowed, so nothing is unreachable
|
|
242
|
+
|
|
243
|
+
allowed = Array(profile.skills).map(&:to_s)
|
|
244
|
+
(eager.map(&:to_s) - allowed).map do |name|
|
|
245
|
+
Finding.new(check: "skill-eager", severity: :warn, fix: nil,
|
|
246
|
+
message: "agent '#{profile.id}' marks skill '#{name}' eager but does not allow it — " \
|
|
247
|
+
"the name is a no-op. Add it to the agent's skills, or drop it from skills_eager.")
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# { name => raw SKILL.md } across BOTH sources the runtime reads: the disk roots
|
|
253
|
+
# (seed, via the catalog) and the authored store (wins — same precedence as
|
|
254
|
+
# SkillCatalog). The checks parse frontmatter the catalog deliberately drops
|
|
255
|
+
# (`eager:`), so they need the raw text; for a disk skill that is the file itself,
|
|
256
|
+
# and a store-overlaid skill carries a sentinel path that is not a file.
|
|
257
|
+
def skill_sources
|
|
258
|
+
@skill_sources ||= disk_skill_sources.merge(@skill_store ? @skill_store.all : {})
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def disk_skill_sources
|
|
262
|
+
return {} unless @skill_catalog
|
|
263
|
+
|
|
264
|
+
@skill_catalog.all.each_with_object({}) do |skill, acc|
|
|
265
|
+
acc[skill.name] = File.read(skill.path, encoding: "UTF-8") if File.file?(skill.path.to_s)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The frontmatter block only; a `eager:` line in the BODY is prose, not config.
|
|
270
|
+
def frontmatter_of(content)
|
|
271
|
+
match = content.to_s.match(/\A---\s*\n(.*?)\n---\s*\n/m)
|
|
272
|
+
match ? Insika::Frontmatter.parse(match[1]) : {}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Drift between a pack's PROSE and the catalog. Three ways it happened on the
|
|
276
|
+
# pilot, all silent, all found by reading a customer conversation afterwards.
|
|
277
|
+
#
|
|
278
|
+
# Every check here takes MECHANICAL inputs only — skill names, allowlists, agent
|
|
279
|
+
# identities. A check that has to parse prose ("this paragraph declares a count of
|
|
280
|
+
# six") false-positives on the first real pack and takes the doctor's credibility
|
|
281
|
+
# with it, which costs more than the drift it caught.
|
|
282
|
+
def check_skill_drift
|
|
283
|
+
return [] unless (@skill_store || @skill_catalog) && @profile_source
|
|
284
|
+
|
|
285
|
+
findings = prompt_files_naming_unallowed_skills + shared_skills_naming_a_holder + broken_companions
|
|
286
|
+
return findings if findings.any?
|
|
287
|
+
|
|
288
|
+
[ok("skill-drift", "skill references: prompt files, shared bodies and companions all consistent")]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# D1 residue. The routing table is GENERATED now (SkillCatalog#format_for_prompt
|
|
292
|
+
# renders each skill with its triggers), so the hand-written companion has no
|
|
293
|
+
# reason to exist — but a pack that still carries one keeps instructing the model
|
|
294
|
+
# about skills the agent cannot load. Skill names are known ids, so this is a grep.
|
|
295
|
+
def prompt_files_naming_unallowed_skills
|
|
296
|
+
return [] unless @agent_file_store
|
|
297
|
+
|
|
298
|
+
catalog = skill_sources.keys
|
|
299
|
+
@profile_source.all.flat_map do |profile|
|
|
300
|
+
next [] if profile.skills.nil? # nil = everything allowed, nothing to be outside of
|
|
301
|
+
|
|
302
|
+
allowed = Array(profile.skills).map(&:to_s)
|
|
303
|
+
orphans = catalog - allowed
|
|
304
|
+
next [] if orphans.empty?
|
|
305
|
+
|
|
306
|
+
@agent_file_store.list(profile.id).flat_map do |file|
|
|
307
|
+
body = @agent_file_store.read(profile.id, file).to_s
|
|
308
|
+
orphans.select { |name| mentions?(body, name) }.map do |name|
|
|
309
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
310
|
+
message: "agent '#{profile.id}' file '#{file}' names skill '#{name}', which is NOT in its " \
|
|
311
|
+
"skills allowlist — the model is being told to use something it cannot load. " \
|
|
312
|
+
"Allow the skill, or drop the reference (the skill table is generated).")
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# D2. A skill in more than one allowlist that names one of its OWN holders in its
|
|
319
|
+
# text is specialized text in shared clothing — the pilot served the Cacau Show
|
|
320
|
+
# agent three shared skills that each said "na Natura". Specialize it per agent
|
|
321
|
+
# (write_skill with `agent:`) instead of leaving one store's policy in a shared body.
|
|
322
|
+
#
|
|
323
|
+
# Merchant vocabulary beyond the holders' identities is deliberately out of scope:
|
|
324
|
+
# there is no mechanical source for it.
|
|
325
|
+
def shared_skills_naming_a_holder
|
|
326
|
+
holders = skill_holders
|
|
327
|
+
specialized = specialized_by
|
|
328
|
+
skill_sources.flat_map do |name, content|
|
|
329
|
+
owners = holders[name].to_a
|
|
330
|
+
next [] if owners.length < 2
|
|
331
|
+
|
|
332
|
+
# Who still READS this shared body: a holder with its own version reads that
|
|
333
|
+
# instead, so it stops being a victim — but its identity in the shared text
|
|
334
|
+
# keeps poisoning whoever is left. Identity comes from ALL holders; only the
|
|
335
|
+
# readers shrink as specializations land, so the finding clears when the last
|
|
336
|
+
# victim stops reading, never merely because the named holder moved out.
|
|
337
|
+
readers = owners - specialized[name].to_a
|
|
338
|
+
owners.flat_map do |owner|
|
|
339
|
+
victims = (readers - [owner]).sort
|
|
340
|
+
next [] if victims.empty?
|
|
341
|
+
|
|
342
|
+
identity_terms(owner).select { |term| mentions?(content, term) }.map do |term|
|
|
343
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
344
|
+
message: "shared skill '#{name}' names '#{term}', the identity of '#{owner}' — " \
|
|
345
|
+
"#{victims.join(', ')} read(s) #{owner}'s text as their own. Specialize " \
|
|
346
|
+
"'#{owner}' if it needs that text, and remove it from the shared body.")
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# D3 residue, the half `companions:` cannot prevent: a body that points at another
|
|
353
|
+
# catalog skill without declaring it (so the pair can still break apart), and a
|
|
354
|
+
# declared companion an agent is not allowed to load (so the pair breaks for THAT
|
|
355
|
+
# agent, silently, since the engine will not widen an allowlist on its own).
|
|
356
|
+
def broken_companions
|
|
357
|
+
names = skill_sources.keys
|
|
358
|
+
undeclared = skill_sources.flat_map do |name, content|
|
|
359
|
+
declared = declared_companions(content)
|
|
360
|
+
(names - [name] - declared).select { |other| mentions?(body_of(content), other) }.map do |other|
|
|
361
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
362
|
+
message: "skill '#{name}' references skill '#{other}' in its body without declaring it a " \
|
|
363
|
+
"companion — the two can arrive apart, and half a recipe is worse than none. " \
|
|
364
|
+
"Add `companions: [#{other}]` to '#{name}'.")
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
undeclared + companions_outside_allowlists
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Same lenient reading as SkillCatalog#parse_list: a YAML list, or the whole value
|
|
371
|
+
# as one comma-separated String when the tolerant parser had to fall back.
|
|
372
|
+
def declared_companions(content)
|
|
373
|
+
raw = frontmatter_of(content)["companions"]
|
|
374
|
+
Array(raw).flat_map { |c| c.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def companions_outside_allowlists
|
|
378
|
+
declared = skill_sources.each_with_object({}) do |(name, content), acc|
|
|
379
|
+
list = declared_companions(content)
|
|
380
|
+
acc[name] = list unless list.empty?
|
|
381
|
+
end
|
|
382
|
+
return [] if declared.empty?
|
|
383
|
+
|
|
384
|
+
@profile_source.all.flat_map do |profile|
|
|
385
|
+
next [] if profile.skills.nil?
|
|
386
|
+
|
|
387
|
+
allowed = Array(profile.skills).map(&:to_s)
|
|
388
|
+
declared.flat_map do |name, companions|
|
|
389
|
+
next [] unless allowed.include?(name)
|
|
390
|
+
|
|
391
|
+
(companions - allowed).map do |missing|
|
|
392
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
393
|
+
message: "agent '#{profile.id}' allows skill '#{name}' but not its companion '#{missing}' — " \
|
|
394
|
+
"the pair cannot travel together for this agent. Allow '#{missing}' too.")
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# { skill name => [agent ids that have their OWN version] }.
|
|
401
|
+
def specialized_by
|
|
402
|
+
return {} unless @skill_store.respond_to?(:agents)
|
|
403
|
+
|
|
404
|
+
@skill_store.agents.each_with_object({}) do |agent, acc|
|
|
405
|
+
@skill_store.names(agent: agent).each { |name| (acc[name] ||= []) << agent }
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# { skill name => Set(agent ids that allow it explicitly) }. An agent with
|
|
410
|
+
# skills=nil allows everything and is not a "holder": it says nothing about which
|
|
411
|
+
# skills were meant to be shared.
|
|
412
|
+
def skill_holders
|
|
413
|
+
@profile_source.all.each_with_object({}) do |profile, acc|
|
|
414
|
+
next if profile.skills.nil?
|
|
415
|
+
|
|
416
|
+
Array(profile.skills).each { |name| (acc[name.to_s] ||= []) << profile.id }
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# An agent's identity as WORDS: the distinctive tokens of its id plus whatever it
|
|
421
|
+
# calls itself in metadata. Structural tokens (agent/store/bot/…) are dropped and
|
|
422
|
+
# short ones ignored — "store" appears in every retail skill ever written, and one
|
|
423
|
+
# false positive is enough for an operator to stop reading the doctor.
|
|
424
|
+
IDENTITY_STOPWORDS = %w[agent agente store shop loja bot assistant assistente atendimento
|
|
425
|
+
prod staging demo test main default].freeze
|
|
426
|
+
|
|
427
|
+
def identity_terms(agent_id)
|
|
428
|
+
profile = @profile_source.fetch(agent_id)
|
|
429
|
+
meta = profile ? (profile.metadata || {}) : {}
|
|
430
|
+
raw = [agent_id.to_s.split(/[-_.\s]+/), meta["name"], meta["display_name"], meta["store_name"]]
|
|
431
|
+
raw.flatten.compact.map { |t| t.to_s.strip }
|
|
432
|
+
.reject { |t| t.length < 4 || IDENTITY_STOPWORDS.include?(t.downcase) }
|
|
433
|
+
.uniq
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Whole-word, case- and accent-insensitive — the same reading the trigger matcher
|
|
437
|
+
# uses, for the same reason: a substring hit inside a longer word is a false
|
|
438
|
+
# positive, and one of those is enough to lose the operator.
|
|
439
|
+
def mentions?(text, term)
|
|
440
|
+
needle = fold(term)
|
|
441
|
+
return false if needle.empty?
|
|
442
|
+
|
|
443
|
+
/(?<![[:alnum:]])#{Regexp.escape(needle)}(?![[:alnum:]])/.match?(fold(text))
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def fold(text) = text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
|
|
447
|
+
|
|
448
|
+
def body_of(content) = content.to_s.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
|
|
449
|
+
|
|
205
450
|
def platform_chat_rate_limit
|
|
206
451
|
value = ((@settings_store&.get || {})["edge"] || {})["chat_rate_limit"]
|
|
207
452
|
value.to_i.positive?
|
data/lib/insika/dsl/runtime.rb
CHANGED
|
@@ -26,16 +26,16 @@ module Insika
|
|
|
26
26
|
# the runtime does not care how many agents it hosts, only that each one
|
|
27
27
|
# arrives as an ordinary Pack.
|
|
28
28
|
#
|
|
29
|
-
# `backend`: the store this graph owns
|
|
29
|
+
# `backend`: the store this graph owns. nil = the historic
|
|
30
30
|
# path, `INSIKA_DB` or memory — ENV is a default, never a requirement (the
|
|
31
|
-
# embed contract
|
|
31
|
+
# embed contract). An injected backend wins and is never widened
|
|
32
32
|
# back to the environment.
|
|
33
33
|
def initialize(definition, backend: nil)
|
|
34
34
|
@definition = definition
|
|
35
35
|
@injected_backend = backend
|
|
36
36
|
@packs = (definition.respond_to?(:packs) ? Array(definition.packs) : [definition.pack]).freeze
|
|
37
37
|
@pack = @packs.first
|
|
38
|
-
#
|
|
38
|
+
# the graph's OWN RubyLLM config, built before the graph so
|
|
39
39
|
# every collaborator that talks to a provider is handed it at wiring time
|
|
40
40
|
# rather than reading a process-wide singleton at call time.
|
|
41
41
|
@llm = build_llm_context
|
|
@@ -44,7 +44,7 @@ module Insika
|
|
|
44
44
|
import_packs
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# The graph's RubyLLM context
|
|
47
|
+
# The graph's RubyLLM context — an isolated config dup that
|
|
48
48
|
# answers #chat. nil only under the test stub, where the fallback is the
|
|
49
49
|
# RubyLLM constant itself. Read by the specs and by anything a host wires
|
|
50
50
|
# alongside this graph.
|
|
@@ -178,7 +178,7 @@ module Insika
|
|
|
178
178
|
guardrails: c[:guardrails], context_providers: context_providers(spine, c),
|
|
179
179
|
edge_limiter: c[:edge_limiter],
|
|
180
180
|
executor_extra: { settings_store: c[:settings_store], tool_trace_store: c[:tool_trace_store],
|
|
181
|
-
llm: @llm }
|
|
181
|
+
context_trace_store: c[:context_trace_store], llm: @llm }
|
|
182
182
|
)
|
|
183
183
|
register_authoring_commands(graph, c)
|
|
184
184
|
register_workflows(graph)
|
|
@@ -228,17 +228,19 @@ module Insika
|
|
|
228
228
|
system_file_store: Insika::SystemFileStore.new(config_store: config_store),
|
|
229
229
|
mcp_store: Insika::McpStore.new(config_store: config_store),
|
|
230
230
|
tool_trace_store: Insika::ToolTraceStore.new(store: backend),
|
|
231
|
+
context_trace_store: Insika::ContextTraceStore.new(store: backend),
|
|
231
232
|
tool_registry: tool_registry,
|
|
232
233
|
tool_catalog: Insika::ToolCatalog.new(tool_registry: tool_registry),
|
|
233
234
|
skill_catalog: Insika::SkillCatalog.new([], store: skill_store),
|
|
234
235
|
prompt_catalog: Insika::PromptCatalog.new([]),
|
|
235
236
|
profile_source: Insika::StoredProfileSource.new(config_store: config_store),
|
|
236
|
-
#
|
|
237
|
+
# the moderator/validator tiers ask a model too. A graph reading its
|
|
237
238
|
# own key for the turn but the global one for a guardrail would be a
|
|
238
239
|
# credential leak wearing the look of isolation.
|
|
239
240
|
guardrails: Insika::Safety::Factory.new(settings_store: settings_store, llm: @llm),
|
|
240
241
|
edge_limiter: Insika::EdgeLimiter.new(
|
|
241
|
-
ledger: Insika::UsageLedger.new(store: backend), settings_store: settings_store
|
|
242
|
+
ledger: Insika::UsageLedger.new(store: backend), settings_store: settings_store,
|
|
243
|
+
budget_ledger: spine.budget_ledger, event_stream: spine.event_stream
|
|
242
244
|
)
|
|
243
245
|
}
|
|
244
246
|
end
|
|
@@ -251,6 +253,7 @@ module Insika
|
|
|
251
253
|
agent_files: c[:agent_file_store], system_files: c[:system_file_store]
|
|
252
254
|
),
|
|
253
255
|
Insika::Context::Providers::Skill.new(catalog: c[:skill_catalog]),
|
|
256
|
+
Insika::Context::Providers::SkillTrigger.new(catalog: c[:skill_catalog]),
|
|
254
257
|
Insika::Context::Providers::ToolSearch.new(catalog: c[:tool_catalog]),
|
|
255
258
|
Insika::Context::Providers::Memory.new(store: spine.memory_store),
|
|
256
259
|
Insika::Context::Providers::Session.new(session_store: spine.session_store)
|
|
@@ -303,7 +306,7 @@ module Insika
|
|
|
303
306
|
([provider_name] + @packs.filter_map { |p| Insika::Coercion.presence(p.config[:provider])&.to_s }).uniq
|
|
304
307
|
end
|
|
305
308
|
|
|
306
|
-
#
|
|
309
|
+
# the graph's credentials belong to the graph. `RubyLLM.context`
|
|
307
310
|
# is an isolated dup of the config that answers #chat, so two graphs in one
|
|
308
311
|
# process no longer overwrite each other's keys: the global
|
|
309
312
|
# `RubyLLM.configure` is a single slot PER PROVIDER, and the loser of that
|
|
@@ -332,7 +335,7 @@ module Insika
|
|
|
332
335
|
|
|
333
336
|
# The LLMConfigurator's target (Studio: edit a provider key, no restart).
|
|
334
337
|
# Scoped to THIS graph's context — a `RubyLLM.configure` here would reach
|
|
335
|
-
# into every other graph in the process, which is the leak
|
|
338
|
+
# into every other graph in the process, which is the leak exists to
|
|
336
339
|
# close. nil = no context (test stub) -> the configurator's own global
|
|
337
340
|
# default, unchanged.
|
|
338
341
|
def llm_configure
|
|
@@ -25,7 +25,7 @@ module Insika
|
|
|
25
25
|
# Fixed local token: logs into the Studio (cookie) and gates /v1 (Bearer).
|
|
26
26
|
# Never a real secret — override with `token:` or ADMIN_TOKEN.
|
|
27
27
|
@token = token || ENV.fetch("ADMIN_TOKEN", "local-demo")
|
|
28
|
-
#
|
|
28
|
+
# the /v1 app is a value now — the same one a host mounts.
|
|
29
29
|
# This boot adds the Studio and a reactor around it, nothing else.
|
|
30
30
|
@builder = Insika::Server::AppBuilder.new(runtime, token: @token)
|
|
31
31
|
end
|
|
@@ -35,13 +35,13 @@ module Insika
|
|
|
35
35
|
dispatch = Rack::URLMap.new("/studio" => Studio::App, "/" => @builder.app)
|
|
36
36
|
endpoint = Async::HTTP::Endpoint.parse("http://#{@host}:#{@port}")
|
|
37
37
|
middleware = Protocol::Rack::Adapter.new(dispatch)
|
|
38
|
-
#
|
|
38
|
+
# the OTEL bridge is opt-in but must be reachable from the DSL
|
|
39
39
|
# front door too — the convention is worthless if only config.ru can export.
|
|
40
40
|
# nil (the default) -> attach is a no-op and no gem is loaded.
|
|
41
41
|
telemetry = Insika::Telemetry.setup(service_name: ENV.fetch("OTEL_SERVICE_NAME", "insika"))
|
|
42
42
|
|
|
43
43
|
banner(telemetry)
|
|
44
|
-
#
|
|
44
|
+
# first Ctrl-C/SIGTERM closes the intake and drains in-flight
|
|
45
45
|
# turns (INSIKA_DRAIN_TIMEOUT, default 20s); a second signal skips the wait.
|
|
46
46
|
Insika::Shutdown.install(executor: @graph.executor)
|
|
47
47
|
Async do
|
|
@@ -68,6 +68,7 @@ module Insika
|
|
|
68
68
|
settings_store: @rt.component(:settings_store), llm_provider_store: @rt.component(:provider_store),
|
|
69
69
|
mcp_store: @rt.component(:mcp_store), system_file_store: @rt.component(:system_file_store),
|
|
70
70
|
tool_trace_store: @rt.component(:tool_trace_store),
|
|
71
|
+
context_trace_store: @rt.component(:context_trace_store),
|
|
71
72
|
task_store: @graph.task_store, checkpoint_store: @graph.checkpoint_store,
|
|
72
73
|
pending_action_store: @graph.pending_action_store
|
|
73
74
|
)
|
data/lib/insika/dsl/system.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Insika
|
|
|
17
17
|
class System
|
|
18
18
|
attr_reader :definitions, :workflows, :runtime_options
|
|
19
19
|
|
|
20
|
-
# `backend`: the store this system's graph owns
|
|
20
|
+
# `backend`: the store this system's graph owns. nil = the
|
|
21
21
|
# historic path (INSIKA_DB, or memory when unset). Set by `Insika.embed`.
|
|
22
22
|
def initialize(definitions:, workflows: [], runtime: {}, backend: nil)
|
|
23
23
|
@definitions = definitions.freeze
|