insika 0.3.0 → 0.7.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 +180 -0
- data/README.md +45 -10
- data/bin/insika +684 -0
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +94 -403
- data/docs/API.md +5 -5
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +14 -14
- data/docs/CONTEXT.md +9 -7
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +71 -3
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +128 -3
- data/docs/FACTS.md +3 -3
- data/docs/HARVEST.md +5 -6
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +2 -2
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +15 -10
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +14 -9
- data/docs/RELEASING.md +4 -4
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +22 -6
- data/docs/SKILLS.md +11 -2
- data/docs/SOAK.md +2 -2
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +152 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +4 -4
- data/docs/improve.md +20 -0
- data/docs/index.md +8 -5
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +73 -16
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/channel_delivery.rb +1 -1
- data/lib/insika/chat_builder.rb +22 -2
- data/lib/insika/commands/agent_payload.rb +2 -2
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +15 -3
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/context/priority.rb +2 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/prompt.rb +30 -24
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/doctor.rb +176 -8
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +60 -79
- data/lib/insika/dsl/server_boot.rb +23 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +103 -2
- data/lib/insika/env_schema.rb +16 -1
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +11 -0
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/executor.rb +231 -55
- data/lib/insika/followup_policy.rb +2 -25
- data/lib/insika/golden_store.rb +16 -1
- data/lib/insika/grounding/matcher.rb +1 -1
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +115 -31
- data/lib/insika/message_origin.rb +1 -1
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/onboarding.rb +17 -1
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +2 -2
- data/lib/insika/profile_source.rb +8 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/retention.rb +36 -1
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/server/app.rb +105 -15
- data/lib/insika/server/rack_app.rb +5 -1
- data/lib/insika/server/responses.rb +1 -1
- data/lib/insika/skill_catalog.rb +12 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/studio/app.rb +567 -45
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +21 -21
- data/lib/insika/studio/forms.rb +46 -5
- data/lib/insika/studio/nav_icons.rb +14 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +61 -820
- data/lib/insika/studio/views/agents.erb +70 -57
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/evals.erb +2 -2
- data/lib/insika/studio/views/facts.erb +1 -1
- data/lib/insika/studio/views/funnel.erb +1 -1
- data/lib/insika/studio/views/home.erb +106 -67
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +14 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/session.erb +231 -177
- data/lib/insika/studio/views/settings.erb +39 -1
- data/lib/insika/studio/views/skills.erb +1 -1
- data/lib/insika/studio/views/tools.erb +24 -9
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/tick.rb +24 -12
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tools/generate_image.rb +52 -7
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +15 -4
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +184 -12
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +57 -0
- metadata +105 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Demo
|
|
8
|
+
# Populates one instance with enough realistic-looking data to see every
|
|
9
|
+
# loop working at once: a funnel with a frozen baseline, follow-ups in all
|
|
10
|
+
# four states, refinement runs across the lifecycle, pending + resolved
|
|
11
|
+
# approvals, distillation proposals/facts, and a golden set with a
|
|
12
|
+
# baseline. Writes ONLY through the same domain-store APIs a real turn
|
|
13
|
+
# would use (OutcomeStore#create + FunnelFold, FollowupStore#create +
|
|
14
|
+
# transitions, ...) — there is no bulk/bypass path, by design (D1: the
|
|
15
|
+
# engine never hard-codes a shortcut for its own demo).
|
|
16
|
+
#
|
|
17
|
+
# `force: false` (default) is a no-op once the demo agent exists — safe
|
|
18
|
+
# to run more than once. `force: true` re-seeds on top: it re-recomputes
|
|
19
|
+
# the funnel cleanly (FunnelFold#recompute always wipes its own pair
|
|
20
|
+
# first) but APPENDS a fresh batch of followups/refinement runs/
|
|
21
|
+
# approvals/proposals/goldens, because none of those stores expose a
|
|
22
|
+
# scoped bulk-delete that a shared "platform" tenant could safely call
|
|
23
|
+
# without risking another agent's data (FollowupStore#purge/
|
|
24
|
+
# ProposalStore#purge are TENANT-wide, not per-agent).
|
|
25
|
+
class Seeder
|
|
26
|
+
FUNNEL_DAYS = 40
|
|
27
|
+
|
|
28
|
+
def initialize(profiles:, store:, session_store:, task_store:, outcome_store:,
|
|
29
|
+
funnel_store:, followup_store:, refinement_store:,
|
|
30
|
+
pending_action_store:, proposal_store:, memory_store:,
|
|
31
|
+
golden_store:, baseline_store:, event_stream:)
|
|
32
|
+
@profiles = profiles
|
|
33
|
+
@store = store
|
|
34
|
+
@session_store = session_store
|
|
35
|
+
@task_store = task_store
|
|
36
|
+
@outcome_store = outcome_store
|
|
37
|
+
@funnel_store = funnel_store
|
|
38
|
+
@followup_store = followup_store
|
|
39
|
+
@refinement_store = refinement_store
|
|
40
|
+
@pending_action_store = pending_action_store
|
|
41
|
+
@proposal_store = proposal_store
|
|
42
|
+
@memory_store = memory_store
|
|
43
|
+
@golden_store = golden_store
|
|
44
|
+
@baseline_store = baseline_store
|
|
45
|
+
@event_stream = event_stream
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -> { seeded: false, reason:, agent: } | { seeded: true, agent:, counts: {…} }
|
|
49
|
+
def seed!(force: false)
|
|
50
|
+
existing = @profiles.fetch(Demo::AGENT_ID)
|
|
51
|
+
return { seeded: false, reason: "already_seeded", agent: Demo::AGENT_ID } if existing && !force
|
|
52
|
+
|
|
53
|
+
# A distinct suffix per call: `force: true` on top of an existing
|
|
54
|
+
# batch would otherwise collide on FollowupStore's own dedup rule (one
|
|
55
|
+
# pending record per tenant+agent+customer+reason) and raise.
|
|
56
|
+
@batch = SecureRandom.hex(3)
|
|
57
|
+
seed_agent!(existing)
|
|
58
|
+
# `funnel_outcomes` runs LAST: `seed_followups!` writes one extra
|
|
59
|
+
# "purchased" outcome (the nudge's conversion, for the A/B card) —
|
|
60
|
+
# the fold has to see it, or it sits un-folded until some later pass.
|
|
61
|
+
counts = {
|
|
62
|
+
followups: seed_followups!,
|
|
63
|
+
refinement_runs: seed_refinement!,
|
|
64
|
+
approvals: seed_approvals!,
|
|
65
|
+
distillation_proposals: seed_distillation!,
|
|
66
|
+
golden_cases: seed_evals!,
|
|
67
|
+
funnel_outcomes: seed_sessions_and_funnel!
|
|
68
|
+
}
|
|
69
|
+
{ seeded: true, agent: Demo::AGENT_ID, counts: counts }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# --- agent -----------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def seed_agent!(existing)
|
|
77
|
+
handler = existing ? Insika::Commands::UpdateAgent.new(profile_source: @profiles, event_stream: @event_stream)
|
|
78
|
+
: Insika::Commands::CreateAgent.new(profile_source: @profiles, event_stream: @event_stream)
|
|
79
|
+
type = existing ? :update_agent : :create_agent
|
|
80
|
+
handler.call(Insika::Command.build(type, Demo::AGENT_ATTRS))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# --- funnel: FUNNEL_DAYS of outcomes, folded, then frozen -------------
|
|
84
|
+
|
|
85
|
+
def seed_sessions_and_funnel!
|
|
86
|
+
declaration = Insika::FunnelDeclaration.parse!(Demo::AGENT_ATTRS[:funnel])
|
|
87
|
+
created = 0
|
|
88
|
+
FUNNEL_DAYS.downto(1) do |days_ago|
|
|
89
|
+
at = Time.now.utc - (days_ago * 86_400)
|
|
90
|
+
day_counts(at).each { |stage, n| created += record_stage(stage, n, at) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Insika::FunnelFold.new(outcome_store: @outcome_store, funnel_store: @funnel_store,
|
|
94
|
+
profiles: @profiles, store: @store)
|
|
95
|
+
.recompute(tenant: nil, agent: Demo::AGENT_ID, declaration: declaration)
|
|
96
|
+
Insika::Commands::FreezeFunnelBaseline.new(funnel_store: @funnel_store, profiles: @profiles,
|
|
97
|
+
event_stream: @event_stream)
|
|
98
|
+
.call(Insika::Command.build(:freeze_funnel_baseline, { agent: Demo::AGENT_ID }))
|
|
99
|
+
created
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A believable e-commerce dropout: each stage keeps a random fraction
|
|
103
|
+
# of the one before it.
|
|
104
|
+
def day_counts(at)
|
|
105
|
+
greeted = rand(4..9)
|
|
106
|
+
browsing = (greeted * rand(0.5..0.7)).round
|
|
107
|
+
cart = (browsing * rand(0.3..0.45)).round
|
|
108
|
+
checkout = (cart * rand(0.45..0.65)).round
|
|
109
|
+
purchased = (checkout * rand(0.55..0.8)).round
|
|
110
|
+
{ "greeted" => greeted, "browsing" => browsing, "cart_started" => cart,
|
|
111
|
+
"checkout_started" => checkout, "purchased" => purchased }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def record_stage(stage, count, at)
|
|
115
|
+
count.times do
|
|
116
|
+
value = stage == "purchased" ? rand(60.0..320.0).round(2) : 0.0
|
|
117
|
+
@outcome_store.create(tenant: nil, agent: Demo::AGENT_ID, outcome: stage, value: value, at: at)
|
|
118
|
+
end
|
|
119
|
+
count
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# --- follow-ups: one per state ----------------------------------------
|
|
123
|
+
|
|
124
|
+
def seed_followups!
|
|
125
|
+
pending_followup!(customer: batch_customer(1), reason: "cart_abandoned", arm: "control")
|
|
126
|
+
|
|
127
|
+
nudge_session = new_task_session!("I'll take the blue sneakers after all")
|
|
128
|
+
fire_followup!(customer: batch_customer(2), reason: "cart_abandoned", arm: "nudge",
|
|
129
|
+
session: nudge_session[:session], task: nudge_session[:task])
|
|
130
|
+
@outcome_store.create(tenant: nil, agent: Demo::AGENT_ID, session_id: nudge_session[:session].id,
|
|
131
|
+
outcome: "purchased", value: 89.9)
|
|
132
|
+
|
|
133
|
+
control_session = new_task_session!("thinking about it, maybe later")
|
|
134
|
+
fire_followup!(customer: batch_customer(3), reason: "cart_abandoned", arm: "control",
|
|
135
|
+
session: control_session[:session], task: control_session[:task])
|
|
136
|
+
|
|
137
|
+
cancelled = pending_followup!(customer: batch_customer(4), reason: "no_response", arm: "nudge")
|
|
138
|
+
@followup_store.cancel(id: cancelled.id)
|
|
139
|
+
|
|
140
|
+
blocked = pending_followup!(customer: batch_customer(5), reason: "reminder", arm: "nudge")
|
|
141
|
+
@followup_store.block(id: blocked.id, reason: "quiet_hours")
|
|
142
|
+
|
|
143
|
+
5
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Distinct per seed! call (see @batch) — the store refuses two pending
|
|
147
|
+
# records for the same (tenant, agent, customer, reason) tuple, and a
|
|
148
|
+
# force reseed must not collide with the previous batch's still-pending
|
|
149
|
+
# one.
|
|
150
|
+
def batch_customer(n) = "demo-customer-#{n}-#{@batch}"
|
|
151
|
+
|
|
152
|
+
def pending_followup!(customer:, reason:, arm:)
|
|
153
|
+
@followup_store.create(tenant: nil, agent: Demo::AGENT_ID, customer: customer,
|
|
154
|
+
session_id: @session_store.create.id, at: Time.now.utc + 3600,
|
|
155
|
+
reason: reason, arm: arm, transport: "web")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def fire_followup!(customer:, reason:, arm:, session:, task:)
|
|
159
|
+
record = @followup_store.create(tenant: nil, agent: Demo::AGENT_ID, customer: customer,
|
|
160
|
+
session_id: session.id, at: Time.now.utc + 3600,
|
|
161
|
+
reason: reason, arm: arm, transport: "web")
|
|
162
|
+
@followup_store.transition_fired(id: record.id, task_id: task.id)
|
|
163
|
+
finish_task!(task)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# --- refinement: one run per stage of the lifecycle -------------------
|
|
167
|
+
|
|
168
|
+
def seed_refinement!
|
|
169
|
+
awaiting_approval_run!
|
|
170
|
+
applied_run!
|
|
171
|
+
rejected_run!
|
|
172
|
+
no_findings_run!
|
|
173
|
+
4
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def awaiting_approval_run!
|
|
177
|
+
run = @refinement_store.create(agent_id: Demo::AGENT_ID, window: { "last_sessions" => 50 })
|
|
178
|
+
@refinement_store.complete(run.id, findings: [repeated_price_finding])
|
|
179
|
+
candidate = discipline_candidate
|
|
180
|
+
@refinement_store.gating(run.id, candidate: candidate)
|
|
181
|
+
@refinement_store.gated(run.id, report: passing_gate_report(candidate))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def applied_run!
|
|
185
|
+
run = @refinement_store.create(agent_id: Demo::AGENT_ID, window: { "last_sessions" => 50 })
|
|
186
|
+
@refinement_store.complete(run.id, findings: [repeated_price_finding])
|
|
187
|
+
candidate = discipline_candidate
|
|
188
|
+
@refinement_store.gating(run.id, candidate: candidate)
|
|
189
|
+
@refinement_store.gated(run.id, report: passing_gate_report(candidate))
|
|
190
|
+
@refinement_store.resolve(run.id, decision: :applied, operator: "demo-seed",
|
|
191
|
+
note: "Looks safe — applying to AGENTS.md.")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def rejected_run!
|
|
195
|
+
run = @refinement_store.create(agent_id: Demo::AGENT_ID, window: { "last_sessions" => 30 })
|
|
196
|
+
@refinement_store.complete(run.id, findings: [off_topic_finding])
|
|
197
|
+
candidate = off_topic_candidate
|
|
198
|
+
@refinement_store.gating(run.id, candidate: candidate)
|
|
199
|
+
@refinement_store.gated(run.id, report: failing_gate_report(candidate))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def no_findings_run!
|
|
203
|
+
run = @refinement_store.create(agent_id: Demo::AGENT_ID, window: { "last_sessions" => 20 })
|
|
204
|
+
@refinement_store.complete(run.id, findings: [])
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def repeated_price_finding
|
|
208
|
+
{ "kind" => "repeated_price_without_stock_check", "count" => 4,
|
|
209
|
+
"title" => "Repeats a price without checking stock",
|
|
210
|
+
"detail" => "asked the price of the same item twice; the agent repeated it verbatim " \
|
|
211
|
+
"without a stock check" }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def off_topic_finding
|
|
215
|
+
{ "kind" => "answers_off_topic_questions", "count" => 2,
|
|
216
|
+
"title" => "Answers questions unrelated to the store",
|
|
217
|
+
"detail" => "answered a question about the weather instead of redirecting to store topics" }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def discipline_candidate
|
|
221
|
+
{ "id" => SecureRandom.uuid, "proposer" => "demo-seed",
|
|
222
|
+
"rationale" => "Customers who ask about the same product twice get the same price restated " \
|
|
223
|
+
"without a stock check — a stale price can be quoted after a sellout.",
|
|
224
|
+
"edits" => [{ "file" => "AGENTS.md", "op" => "append", "anchor" => nil, "before" => nil,
|
|
225
|
+
"after" => "Always confirm current stock before repeating a price.",
|
|
226
|
+
"addresses" => ["repeated_price_without_stock_check"] }],
|
|
227
|
+
"dropped" => [] }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def off_topic_candidate
|
|
231
|
+
{ "id" => SecureRandom.uuid, "proposer" => "demo-seed",
|
|
232
|
+
"rationale" => "The agent occasionally answers off-topic questions instead of redirecting.",
|
|
233
|
+
"edits" => [{ "file" => "AGENTS.md", "op" => "append", "anchor" => nil, "before" => nil,
|
|
234
|
+
"after" => "Never answer questions unrelated to the store; redirect politely.",
|
|
235
|
+
"addresses" => ["answers_off_topic_questions"] }],
|
|
236
|
+
"dropped" => [] }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def passing_gate_report(candidate)
|
|
240
|
+
{ "passed" => true, "candidate_id" => candidate["id"], "cases" => 12, "passed_cases" => 12,
|
|
241
|
+
"regressions" => [] }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def failing_gate_report(candidate)
|
|
245
|
+
{ "passed" => false, "candidate_id" => candidate["id"], "cases" => 10, "passed_cases" => 7,
|
|
246
|
+
"regressions" => ["demo-store-off-topic"], "reason" => "3 case(s) regressed" }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# --- approvals: 2 pending + 1 resolved --------------------------------
|
|
250
|
+
|
|
251
|
+
def seed_approvals!
|
|
252
|
+
[
|
|
253
|
+
["I want a refund for order #10234, the shoes don't fit", "issue_refund",
|
|
254
|
+
{ "order_id" => "10234", "amount" => 89.9 }],
|
|
255
|
+
["can you apply the WELCOME10 discount code retroactively?", "apply_discount_code",
|
|
256
|
+
{ "order_id" => "10391", "code" => "WELCOME10" }]
|
|
257
|
+
].each do |message, tool, args|
|
|
258
|
+
task = new_task_session!(message)[:task]
|
|
259
|
+
@pending_action_store.create(task_id: task.id, turn: 3, tool: tool, args: args)
|
|
260
|
+
finish_task!(task)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
task = new_task_session!("please cancel order #10500 and refund me")[:task]
|
|
264
|
+
pending = @pending_action_store.create(task_id: task.id, turn: 2, tool: "cancel_order",
|
|
265
|
+
args: { "order_id" => "10500" })
|
|
266
|
+
@pending_action_store.resolve(pending.id, decision: :approved, operator: "demo-seed")
|
|
267
|
+
finish_task!(task)
|
|
268
|
+
|
|
269
|
+
3
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# --- distillation: proposals + one approved fact ----------------------
|
|
273
|
+
|
|
274
|
+
def seed_distillation!
|
|
275
|
+
session6 = distillation_session!("demo-customer-6", "I usually pay with Pix, is that ok here too?")
|
|
276
|
+
@proposal_store.create(tenant: nil, customer: "demo-customer-6", session_ref: session6.id,
|
|
277
|
+
key: "preferred_payment_method", value: "pix", confidence: 0.82, evidence: [0])
|
|
278
|
+
|
|
279
|
+
session7 = distillation_session!("demo-customer-7", "I'm always browsing the electronics section")
|
|
280
|
+
approved = @proposal_store.create(tenant: nil, customer: "demo-customer-7", session_ref: session7.id,
|
|
281
|
+
key: "favorite_category", value: "electronics", confidence: 0.9,
|
|
282
|
+
evidence: [0])
|
|
283
|
+
@proposal_store.approve(id: approved.id, operator: "demo-seed", note: "Confirmed across 3 conversations.")
|
|
284
|
+
@memory_store.put_fact(tenant: nil, customer: "demo-customer-7", key: "favorite_category",
|
|
285
|
+
value: "electronics", origin: "distilled:#{approved.id}")
|
|
286
|
+
|
|
287
|
+
session8 = distillation_session!("demo-customer-8", "I wear a size 42, I think")
|
|
288
|
+
rejected = @proposal_store.create(tenant: nil, customer: "demo-customer-8", session_ref: session8.id,
|
|
289
|
+
key: "shoe_size", value: "42", confidence: 0.4, evidence: [0])
|
|
290
|
+
@proposal_store.reject(id: rejected.id, operator: "demo-seed", note: "Confidence too low, likely a typo.")
|
|
291
|
+
|
|
292
|
+
3
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# A real Session (not a bare string) so the Facts page's evidence link
|
|
296
|
+
# resolves — a plain id would 404 on /studio/sessions/:id.
|
|
297
|
+
def distillation_session!(customer, message)
|
|
298
|
+
session = @session_store.create(vars: { "customer" => customer, "agent" => Demo::AGENT_ID })
|
|
299
|
+
@session_store.append_messages(session.id, { "role" => "user", "content" => message })
|
|
300
|
+
session
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# --- evals: the golden set + a mixed pass/fail baseline ---------------
|
|
304
|
+
|
|
305
|
+
def seed_evals!
|
|
306
|
+
ids = Demo::GOLDEN_CASES.map { |raw| @golden_store.write(raw).id }
|
|
307
|
+
last = ids.size - 1
|
|
308
|
+
cases = ids.each_with_index.to_h do |id, i|
|
|
309
|
+
[id, i == last ? { "pass" => false, "score" => 0.42 } : { "pass" => true, "score" => (0.75 + i * 0.03).round(2) }]
|
|
310
|
+
end
|
|
311
|
+
@baseline_store.put(Demo::AGENT_ID, { "cases" => cases })
|
|
312
|
+
ids.size
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# --- shared: a Session + one user Task, like a real turn would leave --
|
|
316
|
+
|
|
317
|
+
def new_task_session!(message)
|
|
318
|
+
session = @session_store.create(vars: { "customer" => SecureRandom.uuid, "agent" => Demo::AGENT_ID })
|
|
319
|
+
command = Insika::Command.build(:send_message, { agent: Demo::AGENT_ID, message: message })
|
|
320
|
+
task = @task_store.create(command: command.to_h, session_id: session.id)
|
|
321
|
+
{ session: session, task: task }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Lands a fresh task in a TERMINAL status (queued -> running ->
|
|
325
|
+
# completed). A seeded task must never sit at :running/:waiting/:paused
|
|
326
|
+
# (or even :queued) once seeding is done — Recovery's boot sweep reads
|
|
327
|
+
# those as an interrupted turn and either dispatches a real resume (no
|
|
328
|
+
# LLM configured -> a pointless failure) or fails the task outright for
|
|
329
|
+
# want of a checkpoint that was never real. Terminal is inert either way.
|
|
330
|
+
def finish_task!(task)
|
|
331
|
+
@task_store.transition(task.id, to: :running)
|
|
332
|
+
@task_store.transition(task.id, to: :completed)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
data/lib/insika/doctor.rb
CHANGED
|
@@ -104,7 +104,8 @@ module Insika
|
|
|
104
104
|
shadow_pair_store: nil, soak_envelope_path: nil, context_providers: nil,
|
|
105
105
|
memory_store: nil, agent_ids: nil, funnel_store: nil, outcome_store: nil,
|
|
106
106
|
followup_store: nil, contact_store: nil, proposal_store: nil,
|
|
107
|
-
|
|
107
|
+
schedule_store: nil,
|
|
108
|
+
harvest_store: nil, harvest_criterion: nil, mcp_store: nil)
|
|
108
109
|
@env = env
|
|
109
110
|
@settings_store = settings_store
|
|
110
111
|
@llm_provider_store = llm_provider_store
|
|
@@ -136,10 +137,16 @@ module Insika
|
|
|
136
137
|
# the distillation check — nil collaborator = the check
|
|
137
138
|
# reports declarations only (counts skipped).
|
|
138
139
|
@proposal_store = proposal_store
|
|
140
|
+
# the schedules check — nil collaborator = the check
|
|
141
|
+
# reports declarations only (skip counts skipped).
|
|
142
|
+
@schedule_store = schedule_store
|
|
139
143
|
# the harvest check — nil collaborators = the check
|
|
140
144
|
# reports declarations only (counts skipped).
|
|
141
145
|
@harvest_store = harvest_store
|
|
142
146
|
@harvest_criterion = harvest_criterion
|
|
147
|
+
# the mcp check — nil collaborator = the check reports nothing
|
|
148
|
+
# (env-only callers stay cheap).
|
|
149
|
+
@mcp_store = mcp_store
|
|
143
150
|
end
|
|
144
151
|
|
|
145
152
|
# -> Report. Never raises (a broken check degrades to an :error Finding).
|
|
@@ -168,11 +175,11 @@ module Insika
|
|
|
168
175
|
private
|
|
169
176
|
|
|
170
177
|
def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
|
|
171
|
-
check_admin_token check_data_tools check_prompt_files
|
|
172
|
-
check_web_widget check_skill_eager check_skill_drift check_shadow_parity
|
|
178
|
+
check_admin_token check_web_concurrency check_data_tools check_mcp check_prompt_files
|
|
179
|
+
check_relay_channel check_web_widget check_skill_eager check_skill_drift check_shadow_parity
|
|
173
180
|
check_soak_envelope check_turn_timing check_grounding check_cache_layers
|
|
174
181
|
check_memory_scopes check_funnel_declarations check_followup check_distill
|
|
175
|
-
check_harvest check_guardrail_corpora]
|
|
182
|
+
check_harvest check_schedules check_guardrail_corpora]
|
|
176
183
|
|
|
177
184
|
def safe(check)
|
|
178
185
|
Array(send(check))
|
|
@@ -336,6 +343,54 @@ module Insika
|
|
|
336
343
|
message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
|
|
337
344
|
end
|
|
338
345
|
|
|
346
|
+
# WEB_CONCURRENCY>1 is a legitimate config (docs/DEPLOY.md "The process
|
|
347
|
+
# model") — but it silently drops per-worker session guarantees
|
|
348
|
+
# (FIFO/collect/steer) unless the operator has sticky routing per session in
|
|
349
|
+
# front, and nothing else in the deployment surfaces that drift. This is
|
|
350
|
+
# exactly how a staging service ended up at N=4 unnoticed and leaked a reply
|
|
351
|
+
# across sessions. The engine cannot see whether sticky routing exists off
|
|
352
|
+
# Railway, so that case only warns — N>1 stays a valid choice for an
|
|
353
|
+
# operator who put a real sticky proxy in front. On Railway it is not a
|
|
354
|
+
# valid choice TODAY: Railway's own docs say it "does not support sticky
|
|
355
|
+
# sessions" and randomly distributes traffic — there is no way to satisfy
|
|
356
|
+
# the precondition on this platform, so this errors instead of warning.
|
|
357
|
+
# RAILWAY_ENVIRONMENT_NAME is injected into every Railway deployment
|
|
358
|
+
# (docs.railway.com/variables/reference), so its presence is a reliable
|
|
359
|
+
# "are we on Railway" signal.
|
|
360
|
+
def check_web_concurrency
|
|
361
|
+
n = @env["WEB_CONCURRENCY"].to_i
|
|
362
|
+
return [ok("web-concurrency", "WEB_CONCURRENCY=1 (default) — session semantics hold cluster-wide")] if n <= 1
|
|
363
|
+
|
|
364
|
+
if router_detected?
|
|
365
|
+
return [ok("web-concurrency", "WEB_CONCURRENCY=#{n} behind a detected insika-router " \
|
|
366
|
+
"sticky routing satisfies the precondition")]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
if Insika::Coercion.present?(@env["RAILWAY_ENVIRONMENT_NAME"])
|
|
370
|
+
return [Finding.new(check: "web-concurrency", severity: :error,
|
|
371
|
+
message: "WEB_CONCURRENCY=#{n} on Railway — Railway does not support sticky " \
|
|
372
|
+
"sessions (docs.railway.com/deployments/scaling), so per-session " \
|
|
373
|
+
"FIFO/collect/steer WILL break across workers with no way to fix it " \
|
|
374
|
+
"at the routing layer; set WEB_CONCURRENCY=1, or run insika-router " \
|
|
375
|
+
"in front of N local workers instead", fix: nil)]
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
[Finding.new(check: "web-concurrency", severity: :warn,
|
|
379
|
+
message: "WEB_CONCURRENCY=#{n} — per-session FIFO/collect/steer only hold on the worker " \
|
|
380
|
+
"that owns the session; make sure sticky routing per session sits in front (e.g. " \
|
|
381
|
+
"insika-router, set INSIKA_ROUTER_BACKENDS/_DNS), or accept " \
|
|
382
|
+
"per-worker best-effort (docs/DEPLOY.md \"The process model\")", fix: nil)]
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# A router is "detected", not verified — the doctor cannot see whether a
|
|
386
|
+
# process at these addresses actually runs `insika-router`, only that the
|
|
387
|
+
# operator configured one. Same precedent as every other
|
|
388
|
+
# env-based capability check in this file.
|
|
389
|
+
def router_detected?
|
|
390
|
+
Insika::Coercion.present?(@env["INSIKA_ROUTER_BACKENDS"]) ||
|
|
391
|
+
Insika::Coercion.present?(@env["INSIKA_ROUTER_BACKENDS_DNS"])
|
|
392
|
+
end
|
|
393
|
+
|
|
339
394
|
# the soak envelope (a deployment-side file). Absent is :info — not every
|
|
340
395
|
# deployment soaks, and the tooling is optional. Present-and-broken is
|
|
341
396
|
# :error — an envelope that exists but does not parse is a pre-declaration
|
|
@@ -694,11 +749,50 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
694
749
|
def check_data_tools
|
|
695
750
|
return [] unless @tool_store
|
|
696
751
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
752
|
+
raws = @tool_store.all_raw
|
|
753
|
+
broken = raws.filter_map { |raw| broken_tool(raw) }
|
|
754
|
+
legacy = raws.select { |raw| raw["group"].to_s.start_with?("mcp:") }.map { |raw| legacy_mcp_snapshot(raw) }
|
|
755
|
+
base = broken.empty? ? [ok("data-tools", "#{raws.length} data tool(s): every definition valid")] : broken
|
|
756
|
+
base + legacy
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# A data-tool in an `mcp:*` group was ingested by the
|
|
760
|
+
# RETIRED snapshot path (McpToolIngestor) — the live registry now serves
|
|
761
|
+
# that same MCP instance's tools directly. Still executes fine (never
|
|
762
|
+
# removed automatically), just a duplicate worth cleaning up.
|
|
763
|
+
def legacy_mcp_snapshot(raw)
|
|
764
|
+
Finding.new(check: "data-tools", severity: :info, fix: nil,
|
|
765
|
+
message: "data tool '#{raw["name"]}' (group #{raw["group"]}) is a legacy MCP " \
|
|
766
|
+
"snapshot — that instance's tools now run LIVE via the MCP registry; " \
|
|
767
|
+
"this frozen copy still works but duplicates it, safe to remove.")
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# An http/sse instance whose credentials still sit under `env`
|
|
771
|
+
# (the pre-RFC meaning) is READ as `headers` (McpStore#raw), but the
|
|
772
|
+
# record on disk is unchanged until the operator re-saves it — flag it so
|
|
773
|
+
# it doesn't linger silently. A stdio instance that is enabled but
|
|
774
|
+
# INSIKA_MCP_STDIO is not set will save fine but refuse to start.
|
|
775
|
+
def check_mcp
|
|
776
|
+
return [] unless @mcp_store
|
|
777
|
+
|
|
778
|
+
raws = @mcp_store.all_raw
|
|
779
|
+
gated = raws.select do |r|
|
|
780
|
+
r["transport"].to_s == "stdio" && r["enabled"] &&
|
|
781
|
+
!Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_MCP_STDIO", @env))
|
|
782
|
+
end
|
|
783
|
+
findings = @mcp_store.legacy_header_names.map do |name|
|
|
784
|
+
Finding.new(check: "mcp", severity: :warn, fix: nil,
|
|
785
|
+
message: "MCP instance '#{name}' still stores credentials under 'env' — " \
|
|
786
|
+
"re-save it so they move to 'headers' (env is now stdio-only).")
|
|
787
|
+
end
|
|
788
|
+
findings += gated.map do |r|
|
|
789
|
+
Finding.new(check: "mcp", severity: :info, fix: nil,
|
|
790
|
+
message: "MCP instance '#{r["name"]}' is stdio and enabled, but INSIKA_MCP_STDIO " \
|
|
791
|
+
"is not set — it will refuse to start.")
|
|
792
|
+
end
|
|
793
|
+
return [ok("mcp", "#{raws.length} MCP instance(s): no legacy env-as-headers records")] if findings.empty?
|
|
700
794
|
|
|
701
|
-
|
|
795
|
+
findings
|
|
702
796
|
end
|
|
703
797
|
|
|
704
798
|
# the doctor cannot run a turn, so it cannot prove purity — it
|
|
@@ -976,6 +1070,80 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
976
1070
|
e.message
|
|
977
1071
|
end
|
|
978
1072
|
|
|
1073
|
+
# the recurring-schedule check — declarations
|
|
1074
|
+
# validated where they are declared (each one through the SAME
|
|
1075
|
+
# Insika::Schedule parser the engine fires on, never a second opinion),
|
|
1076
|
+
# plus the data read that answers "what is being skipped right now".
|
|
1077
|
+
# A bare install (no schedule on any profile) reports NOTHING.
|
|
1078
|
+
def check_schedules
|
|
1079
|
+
return [] unless @profile_source
|
|
1080
|
+
|
|
1081
|
+
declared = @profile_source.all.select do |profile|
|
|
1082
|
+
profile.respond_to?(:schedules) && Array(profile.schedules).any?
|
|
1083
|
+
end
|
|
1084
|
+
return [] if declared.empty?
|
|
1085
|
+
|
|
1086
|
+
declared.flat_map do |profile|
|
|
1087
|
+
Array(profile.schedules).flat_map do |schedule|
|
|
1088
|
+
decl = Insika::Schedule.parse(schedule)
|
|
1089
|
+
if decl.nil?
|
|
1090
|
+
[Finding.new(check: "schedules", severity: :error, fix: nil,
|
|
1091
|
+
message: "agent '#{profile.id}': malformed schedule — " \
|
|
1092
|
+
"#{schedule_defect(schedule)}. The engine will never " \
|
|
1093
|
+
"fire it until this is fixed.")]
|
|
1094
|
+
else
|
|
1095
|
+
ok_finding = schedule_ok(profile, decl)
|
|
1096
|
+
[ok_finding] + schedule_data_findings(profile, decl)
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
end
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
# The ok finding for one valid declaration — plus the warning that
|
|
1103
|
+
# names the cadence ceiling: the engine claims ONE window per pass, so an
|
|
1104
|
+
# `every` shorter than the claim window cannot fire more often than it.
|
|
1105
|
+
def schedule_ok(profile, decl)
|
|
1106
|
+
trigger = decl.cron ? "cron #{decl.cron} <#{decl.tz}>" : "every #{decl.every}s"
|
|
1107
|
+
floor = Insika::ScheduleEngine::DEFAULT_WINDOW
|
|
1108
|
+
if !decl.cron.nil? || decl.every >= floor
|
|
1109
|
+
ok("schedules",
|
|
1110
|
+
"agent '#{profile.id}': schedule '#{decl.id}' — #{trigger}, " \
|
|
1111
|
+
"#{decl.fixed_session? ? "a fixed standing session" : 'a new session per run'}, " \
|
|
1112
|
+
"#{decl.enabled ? 'enabled' : 'disabled'}")
|
|
1113
|
+
else
|
|
1114
|
+
Finding.new(check: "schedules", severity: :warn, fix: nil,
|
|
1115
|
+
message: "agent '#{profile.id}': schedule '#{decl.id}' declares " \
|
|
1116
|
+
"every #{decl.every}s, but the engine fires at most once per " \
|
|
1117
|
+
"#{floor}s claim window — the effective cadence is one run " \
|
|
1118
|
+
"per ~#{floor}s, not one per #{decl.every}s.")
|
|
1119
|
+
end
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
# The named defect, for the error message — the engine's parser's words.
|
|
1123
|
+
def schedule_defect(hash)
|
|
1124
|
+
Insika::Schedule.parse!(hash).to_s
|
|
1125
|
+
rescue Insika::ValidationError => e
|
|
1126
|
+
e.message
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1129
|
+
# Data-age findings, gated on the optional collaborator (nil = skip,
|
|
1130
|
+
# env-only callers stay cheap): the row's last skip, when the schedule
|
|
1131
|
+
# was created and has one — visible in the Studio, named here too.
|
|
1132
|
+
def schedule_data_findings(profile, decl)
|
|
1133
|
+
return [] unless @schedule_store
|
|
1134
|
+
|
|
1135
|
+
row = @schedule_store.find(tenant: Insika::ScheduleEngine.tenant_for(profile),
|
|
1136
|
+
agent: profile.id.to_s, id: decl.id)
|
|
1137
|
+
return [] unless row&.last_skip
|
|
1138
|
+
|
|
1139
|
+
[Finding.new(check: "schedules", severity: :info, fix: nil,
|
|
1140
|
+
message: "agent '#{profile.id}' schedule '#{decl.id}': the last run was " \
|
|
1141
|
+
"SKIPPED at #{row.last_skip['at']} — reason " \
|
|
1142
|
+
"#{row.last_skip['reason'].inspect} (overlap = the previous " \
|
|
1143
|
+
"run still live; budget = a hard cap reached; late = the " \
|
|
1144
|
+
"window was missed by the no-catch-up policy).")]
|
|
1145
|
+
end
|
|
1146
|
+
|
|
979
1147
|
# Data-age findings, gated on the optional collaborators (nil = skip,
|
|
980
1148
|
# env-only callers stay cheap).
|
|
981
1149
|
def followup_data_findings(profile, decl)
|
|
@@ -11,11 +11,12 @@ module Insika
|
|
|
11
11
|
# exactly like any other pack. That is what makes `#profile` identical to a
|
|
12
12
|
# hand-written equivalent pack (the parity spec) — there is only one path.
|
|
13
13
|
class Definition
|
|
14
|
-
attr_reader :pack, :runtime_options
|
|
14
|
+
attr_reader :pack, :runtime_options, :mcp_instances
|
|
15
15
|
|
|
16
|
-
def initialize(pack:, runtime: {})
|
|
16
|
+
def initialize(pack:, runtime: {}, mcp_instances: [])
|
|
17
17
|
@pack = pack
|
|
18
18
|
@runtime_options = runtime
|
|
19
|
+
@mcp_instances = mcp_instances
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def id = pack.config[:id].to_s
|