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
data/lib/insika/dsl/runtime.rb
CHANGED
|
@@ -14,8 +14,6 @@ module Insika
|
|
|
14
14
|
# Built lazily by Definition#runtime, so `require "insika"` never pays for
|
|
15
15
|
# ruby_llm or the HTTP server.
|
|
16
16
|
class Runtime
|
|
17
|
-
TERMINAL = %i[task_completed task_failed task_cancelled].freeze
|
|
18
|
-
|
|
19
17
|
# graph: the assembled Wiring::Graph::Result; pack: the PRIMARY imported
|
|
20
18
|
# Pack; packs: every imported Pack (one per agent — a System has N, a
|
|
21
19
|
# Definition has one). All read by the server boot and the specs.
|
|
@@ -40,6 +38,11 @@ module Insika
|
|
|
40
38
|
# rather than reading a process-wide singleton at call time.
|
|
41
39
|
@llm = build_llm_context
|
|
42
40
|
@graph = build_graph
|
|
41
|
+
# the dispatch+drain seam `#chat`/`#run_workflow` delegate to —
|
|
42
|
+
# `Insika::Wiring::GraphChat`, shared with `config/deployment.rb`'s own
|
|
43
|
+
# registration of `run_persona_eval` (see `register_persona_eval_tool`
|
|
44
|
+
# below).
|
|
45
|
+
@chat_runtime = Insika::Wiring::GraphChat.new(graph: @graph)
|
|
43
46
|
seed_default_model
|
|
44
47
|
import_packs
|
|
45
48
|
end
|
|
@@ -62,12 +65,8 @@ module Insika
|
|
|
62
65
|
# one-shot; a value threads multi-turn memory (created on first use).
|
|
63
66
|
# `agent:` targets one agent of a system (default: the primary).
|
|
64
67
|
def chat(message, session_id: nil, timeout: nil, agent: nil)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{ agent: (agent || @definition.id).to_s, message: message, session_id: session_id },
|
|
68
|
-
transport: :cli
|
|
69
|
-
)
|
|
70
|
-
run_command(command, session_id: session_id, timeout: timeout)[:text]
|
|
68
|
+
@chat_runtime.chat(message, agent: (agent || @definition.id).to_s,
|
|
69
|
+
session_id: session_id, timeout: timeout)
|
|
71
70
|
end
|
|
72
71
|
|
|
73
72
|
# Triggers a declared workflow and returns its OUTPUT (the typed value the
|
|
@@ -78,7 +77,7 @@ module Insika
|
|
|
78
77
|
command = Insika::Command.build(
|
|
79
78
|
:trigger_workflow, { workflow: name, agent: agent, input: input }, transport: :cli
|
|
80
79
|
)
|
|
81
|
-
outcome = run_command(command, session_id: nil, timeout: timeout)
|
|
80
|
+
outcome = @chat_runtime.run_command(command, session_id: nil, timeout: timeout)
|
|
82
81
|
outcome.key?(:output) ? outcome[:output] : outcome[:text]
|
|
83
82
|
end
|
|
84
83
|
|
|
@@ -93,70 +92,6 @@ module Insika
|
|
|
93
92
|
|
|
94
93
|
private
|
|
95
94
|
|
|
96
|
-
# Dispatch + drain, shared by a turn and a workflow run: subscribe BEFORE
|
|
97
|
-
# dispatching (the fiber may emit eagerly), bind to the task, read to the
|
|
98
|
-
# terminal event. A synchronous handler error (unknown agent/workflow, bad
|
|
99
|
-
# input against the schema) propagates from `dispatch` untouched — the
|
|
100
|
-
# caller sees the real ValidationError/NotFoundError, not a turn failure.
|
|
101
|
-
def run_command(command, session_id:, timeout:)
|
|
102
|
-
require "async"
|
|
103
|
-
outcome = {}
|
|
104
|
-
rejected = nil
|
|
105
|
-
Async do |task|
|
|
106
|
-
serving = !session_id.nil?
|
|
107
|
-
@graph.executor.supervised = true if serving
|
|
108
|
-
ensure_session(session_id) if session_id
|
|
109
|
-
sub = @graph.event_stream.subscribe
|
|
110
|
-
res = begin
|
|
111
|
-
@graph.bus.dispatch(command)
|
|
112
|
-
rescue StandardError => e
|
|
113
|
-
# CAPTURED, not re-raised inside the reactor: letting it escape the
|
|
114
|
-
# Async block logs "Task may have ended with unhandled exception" —
|
|
115
|
-
# alarming noise for a DOCUMENTED rejection (a schema violation, an
|
|
116
|
-
# unknown agent). Re-raised verbatim below, outside the reactor.
|
|
117
|
-
sub.close
|
|
118
|
-
rejected = e
|
|
119
|
-
next
|
|
120
|
-
end
|
|
121
|
-
sub.bind(task_id: res[:task_id])
|
|
122
|
-
drain(sub, outcome, task, timeout)
|
|
123
|
-
teardown_serving if serving
|
|
124
|
-
end.wait
|
|
125
|
-
raise rejected if rejected
|
|
126
|
-
raise Insika::Error, "turn #{outcome[:error]}" if outcome[:error]
|
|
127
|
-
|
|
128
|
-
outcome
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# Reads the stream until this turn reaches a terminal event, then stops.
|
|
132
|
-
# A workflow run also carries its typed OUTPUT, which is not the turn text.
|
|
133
|
-
def drain(sub, outcome, task, timeout)
|
|
134
|
-
reader = task.async do
|
|
135
|
-
sub.each do |ev|
|
|
136
|
-
case ev.type
|
|
137
|
-
when :task_completed then outcome[:text] = ev.data[:content].to_s
|
|
138
|
-
when :workflow_completed then outcome[:output] = ev.data[:output]
|
|
139
|
-
when :task_failed then outcome[:error] = "failed: #{ev.data[:message] || ev.data[:error]}"
|
|
140
|
-
when :task_cancelled then outcome[:error] = "cancelled"
|
|
141
|
-
end
|
|
142
|
-
sub.close if TERMINAL.include?(ev.type)
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
timeout ? task.with_timeout(timeout) { reader.wait } : reader.wait
|
|
146
|
-
rescue Async::TimeoutError
|
|
147
|
-
outcome[:error] = "timed out after #{timeout}s"
|
|
148
|
-
sub.close
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def ensure_session(session_id)
|
|
152
|
-
@graph.session_store.find(session_id) || @graph.session_store.create(id: session_id, vars: {})
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def teardown_serving
|
|
156
|
-
@graph.executor.stop_session_actors
|
|
157
|
-
@graph.executor.instance_variable_get(:@supervisor)&.stop
|
|
158
|
-
end
|
|
159
|
-
|
|
160
95
|
# --- graph assembly (mirrors config/deployment.rb, generically) ------
|
|
161
96
|
|
|
162
97
|
def build_graph
|
|
@@ -183,6 +118,20 @@ module Insika
|
|
|
183
118
|
)
|
|
184
119
|
register_authoring_commands(graph, c)
|
|
185
120
|
register_workflows(graph)
|
|
121
|
+
import_mcp_declarations(c)
|
|
122
|
+
# `run_persona_eval` (C3.1): the SAME registration `config/deployment.rb`
|
|
123
|
+
# does for its own graph — see `Wiring::Graph.register_persona_eval_tool`.
|
|
124
|
+
# One implementation, both roots; this one just already has its
|
|
125
|
+
# `golden_store`/`settings_store` at hand from `build_components`.
|
|
126
|
+
Insika::Wiring::Graph.register_persona_eval_tool(
|
|
127
|
+
graph, golden_store: Insika::GoldenStore.new(config_store: c[:config_store]),
|
|
128
|
+
settings_store: c[:settings_store], llm: @llm
|
|
129
|
+
)
|
|
130
|
+
# Same boot step the server roots run: an installed `insika-plugin-*`
|
|
131
|
+
# gem (or INSIKA_PLUGIN_DIR) extends a DSL-run agent too — a plugin
|
|
132
|
+
# works everywhere or it is not a plugin. No bundled root here: the DSL
|
|
133
|
+
# runs outside the repo checkout.
|
|
134
|
+
Insika::Wiring::Graph.load_plugins(graph)
|
|
186
135
|
graph
|
|
187
136
|
end
|
|
188
137
|
|
|
@@ -210,15 +159,42 @@ module Insika
|
|
|
210
159
|
))
|
|
211
160
|
end
|
|
212
161
|
|
|
162
|
+
# Upserts every DSL-declared `mcp` instance into the McpStore at boot.
|
|
163
|
+
# Code is the TEMPLATE — transport/command/args/url/
|
|
164
|
+
# description always follow the declaration — but `enabled` and the
|
|
165
|
+
# credentials (`env`/`headers`) are the operator's once the instance
|
|
166
|
+
# exists: an already-present record's own values are re-sent verbatim
|
|
167
|
+
# instead of the DSL's, so a Studio/CLI/API edit made after boot is never
|
|
168
|
+
# clobbered by the next restart (motor-vs-forja: the store's DATA wins).
|
|
169
|
+
def import_mcp_declarations(c)
|
|
170
|
+
declared = Array(@definition.mcp_instances)
|
|
171
|
+
return if declared.empty?
|
|
172
|
+
|
|
173
|
+
mcp_store = c[:mcp_store]
|
|
174
|
+
declared.each do |decl|
|
|
175
|
+
attrs = decl.dup
|
|
176
|
+
existing = mcp_store.get_raw(attrs[:name])
|
|
177
|
+
if existing
|
|
178
|
+
attrs[:enabled] = existing["enabled"]
|
|
179
|
+
attrs[:env] = existing["env"]
|
|
180
|
+
attrs[:headers] = existing["headers"]
|
|
181
|
+
end
|
|
182
|
+
mcp_store.upsert(attrs)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
213
186
|
def build_components(backend, spine)
|
|
214
187
|
config_store = Insika::ConfigStore.new(store: backend)
|
|
215
188
|
settings_store = Insika::SettingsStore.new(config_store: config_store)
|
|
216
189
|
provider_store = Insika::LLMProviderStore.new(config_store: config_store)
|
|
217
190
|
tool_store = Insika::ToolStore.new(config_store: config_store)
|
|
218
191
|
skill_store = Insika::SkillStore.new(config_store: config_store)
|
|
192
|
+
mcp_store = Insika::McpStore.new(config_store: config_store)
|
|
193
|
+
mcp_tool_registry = Insika::McpToolRegistry.new(mcp_store: mcp_store)
|
|
219
194
|
tool_registry = Insika::OverlayToolRegistry.new(
|
|
220
195
|
base: spine.code_tool_registry, tool_store: tool_store,
|
|
221
|
-
http: Insika::HttpClient.new, event_stream: spine.event_stream
|
|
196
|
+
http: Insika::HttpClient.new, event_stream: spine.event_stream,
|
|
197
|
+
mcp_registry: mcp_tool_registry
|
|
222
198
|
)
|
|
223
199
|
{
|
|
224
200
|
backend: backend, config_store: config_store,
|
|
@@ -227,7 +203,7 @@ module Insika
|
|
|
227
203
|
agent_file_store: Insika::AgentFileStore.new(config_store: config_store),
|
|
228
204
|
skill_store: skill_store, tool_store: tool_store,
|
|
229
205
|
system_file_store: Insika::SystemFileStore.new(config_store: config_store),
|
|
230
|
-
mcp_store:
|
|
206
|
+
mcp_store: mcp_store, mcp_tool_registry: mcp_tool_registry,
|
|
231
207
|
tool_trace_store: Insika::ToolTraceStore.new(store: backend),
|
|
232
208
|
context_trace_store: Insika::ContextTraceStore.new(store: backend),
|
|
233
209
|
# the per-AGENT cache-hit series (the Studio agent-detail card).
|
|
@@ -252,15 +228,16 @@ module Insika
|
|
|
252
228
|
[
|
|
253
229
|
Insika::Context::Providers::Request.new,
|
|
254
230
|
Insika::Context::Providers::Prompt.new(
|
|
255
|
-
base: "",
|
|
231
|
+
base: "", catalog: c[:prompt_catalog],
|
|
256
232
|
agent_files: c[:agent_file_store], system_files: c[:system_file_store]
|
|
257
233
|
),
|
|
258
234
|
Insika::Context::Providers::Skill.new(catalog: c[:skill_catalog]),
|
|
259
235
|
Insika::Context::Providers::SkillTrigger.new(catalog: c[:skill_catalog]),
|
|
260
236
|
Insika::Context::Providers::ToolSearch.new(catalog: c[:tool_catalog]),
|
|
261
237
|
Insika::Context::Providers::Memory.new(store: spine.memory_store),
|
|
238
|
+
Insika::Context::Providers::Knowledge.new(store: spine.knowledge_store),
|
|
262
239
|
Insika::Context::Providers::Session.new(session_store: spine.session_store)
|
|
263
|
-
]
|
|
240
|
+
] # NOT frozen: load_plugins appends plugin providers at boot
|
|
264
241
|
end
|
|
265
242
|
|
|
266
243
|
# The runtime-authoring surface a pack import needs (create_agent +
|
|
@@ -288,14 +265,18 @@ module Insika
|
|
|
288
265
|
bus.register(:update_settings, Insika::Commands::UpdateSettings.new(settings_store: c[:settings_store], event_stream: es))
|
|
289
266
|
bus.register(:upsert_llm_provider, Insika::Commands::UpsertLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
|
|
290
267
|
bus.register(:delete_llm_provider, Insika::Commands::DeleteLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
|
|
291
|
-
bus.register(:upsert_mcp, Insika::Commands::UpsertMcp.new(mcp_store: c[:mcp_store], event_stream: es))
|
|
292
|
-
bus.register(:delete_mcp, Insika::Commands::DeleteMcp.new(mcp_store: c[:mcp_store], event_stream: es))
|
|
268
|
+
bus.register(:upsert_mcp, Insika::Commands::UpsertMcp.new(mcp_store: c[:mcp_store], mcp_registry: c[:mcp_tool_registry], event_stream: es))
|
|
269
|
+
bus.register(:delete_mcp, Insika::Commands::DeleteMcp.new(mcp_store: c[:mcp_store], mcp_registry: c[:mcp_tool_registry], event_stream: es))
|
|
270
|
+
bus.register(:refresh_mcp_tools, Insika::Commands::RefreshMcpTools.new(mcp_registry: c[:mcp_tool_registry], event_stream: es))
|
|
293
271
|
bus.register(:write_system_file, Insika::Commands::WriteSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
|
|
294
272
|
bus.register(:delete_system_file, Insika::Commands::DeleteSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
|
|
295
273
|
bus.register(:restore_system_file, Insika::Commands::RestoreSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
|
|
296
274
|
bus.register(:write_data_tool, Insika::Commands::WriteDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
|
|
297
275
|
bus.register(:delete_data_tool, Insika::Commands::DeleteDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
|
|
298
276
|
bus.register(:restore_data_tool, Insika::Commands::RestoreDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
|
|
277
|
+
bus.register(:write_concept, Insika::Commands::WriteConcept.new(knowledge_store: graph.knowledge_store, event_stream: es))
|
|
278
|
+
bus.register(:delete_concept, Insika::Commands::DeleteConcept.new(knowledge_store: graph.knowledge_store, event_stream: es))
|
|
279
|
+
bus.register(:restore_concept, Insika::Commands::RestoreConcept.new(knowledge_store: graph.knowledge_store, event_stream: es))
|
|
299
280
|
end
|
|
300
281
|
|
|
301
282
|
# --- RubyLLM configuration ------------------------------------------
|
|
@@ -51,6 +51,10 @@ module Insika
|
|
|
51
51
|
Insika::Shutdown.install(executor: @graph.executor)
|
|
52
52
|
Async do
|
|
53
53
|
@graph.executor.supervised = true # serving mode: turns survive disconnects
|
|
54
|
+
# forces the tick/alert/distill/harvest supervisor up now — otherwise a
|
|
55
|
+
# deployment with only scheduled agents (no live chat) never ticks until
|
|
56
|
+
# unrelated traffic happens to hit turn_parent first.
|
|
57
|
+
@graph.executor.start_supervisor!
|
|
54
58
|
Insika::Telemetry.attach(event_stream: @graph.event_stream, recorder: telemetry)
|
|
55
59
|
Async::HTTP::Server.new(middleware, endpoint).run
|
|
56
60
|
end
|
|
@@ -122,10 +126,28 @@ module Insika
|
|
|
122
126
|
# (nil = the page renders the refusal/empty states).
|
|
123
127
|
harvest_store: @graph.harvest_store,
|
|
124
128
|
harvest_criterion: harvest_criterion,
|
|
125
|
-
negative_list: harvest_negative_list
|
|
129
|
+
negative_list: harvest_negative_list,
|
|
130
|
+
# the Knowledge page reads the store directly; its
|
|
131
|
+
# mutations (write/delete/restore) dispatch bus commands.
|
|
132
|
+
knowledge_store: @graph.knowledge_store,
|
|
133
|
+
# the Artifacts tab reads the report store; the signing
|
|
134
|
+
# config (nil without INSIKA_ARTIFACT_SIGNING_KEY) decides whether a
|
|
135
|
+
# signed sharing link exists at all.
|
|
136
|
+
artifact_store: @graph.artifact_store,
|
|
137
|
+
artifact_signing: artifact_signing
|
|
126
138
|
)
|
|
127
139
|
end
|
|
128
140
|
|
|
141
|
+
# -> { key:, ttl:, base_url: } | nil (no signing key -> no signed surface).
|
|
142
|
+
def artifact_signing
|
|
143
|
+
key = Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_KEY")
|
|
144
|
+
return nil if key.to_s.empty?
|
|
145
|
+
|
|
146
|
+
{ key: key,
|
|
147
|
+
ttl: (Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_TTL") || 604_800).to_i,
|
|
148
|
+
base_url: Insika::Coercion.presence(Insika::EnvSchema.read("INSIKA_PUBLIC_URL")).to_s }
|
|
149
|
+
end
|
|
150
|
+
|
|
129
151
|
# The frozen criterion — best-effort at boot: a bare install
|
|
130
152
|
# without the env var renders the ruler's hole instead of
|
|
131
153
|
# crashing the Studio.
|
data/lib/insika/dsl/system.rb
CHANGED
|
@@ -15,13 +15,21 @@ module Insika
|
|
|
15
15
|
# require the child agents to be resolvable in the SAME ProfileSource. A
|
|
16
16
|
# Definition owns exactly one pack, so those patterns had no home in the DSL.
|
|
17
17
|
class System
|
|
18
|
-
attr_reader :definitions, :workflows, :runtime_options
|
|
18
|
+
attr_reader :definitions, :workflows, :mcp_instances, :runtime_options
|
|
19
19
|
|
|
20
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
|
-
def initialize(definitions:, workflows: [], runtime: {}, backend: nil)
|
|
22
|
+
def initialize(definitions:, workflows: [], mcp_instances: [], runtime: {}, backend: nil)
|
|
23
23
|
@definitions = definitions.freeze
|
|
24
24
|
@workflows = workflows.freeze
|
|
25
|
+
# MCP instances are global to the graph (one McpStore, not per-agent),
|
|
26
|
+
# so a system-level `mcp` declaration and one nested inside a member
|
|
27
|
+
# `agent { … }` block land in the same set. A name declared both places
|
|
28
|
+
# is NOT an error here (unlike within one collection) — the later one
|
|
29
|
+
# (system-level, applied last) simply wins; Insika::DSL::Runtime upserts
|
|
30
|
+
# this combined list once at boot.
|
|
31
|
+
@mcp_instances = (definitions.flat_map(&:mcp_instances) + mcp_instances)
|
|
32
|
+
.each_with_object({}) { |m, acc| acc[m[:name]] = m }.values.freeze
|
|
25
33
|
@backend = backend
|
|
26
34
|
# Agent-level runtime knobs merged in declaration order, then the
|
|
27
35
|
# system-level ones on top (an explicit `provider`/`api_key` in the
|
data/lib/insika/dsl.rb
CHANGED
|
@@ -47,6 +47,7 @@ module Insika
|
|
|
47
47
|
def initialize
|
|
48
48
|
@definitions = []
|
|
49
49
|
@workflows = []
|
|
50
|
+
@mcp_instances = []
|
|
50
51
|
@runtime = {}
|
|
51
52
|
end
|
|
52
53
|
|
|
@@ -54,7 +55,8 @@ module Insika
|
|
|
54
55
|
instance_eval(&block) if block
|
|
55
56
|
raise ArgumentError, "Insika.system needs at least one agent" if @definitions.empty?
|
|
56
57
|
|
|
57
|
-
System.new(definitions: @definitions, workflows: @workflows,
|
|
58
|
+
System.new(definitions: @definitions, workflows: @workflows,
|
|
59
|
+
mcp_instances: @mcp_instances, runtime: @runtime, backend: backend)
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
# Declares one agent — the SAME block the standalone `Insika.agent` takes.
|
|
@@ -101,6 +103,34 @@ module Insika
|
|
|
101
103
|
def provider(name) = @runtime[:provider] = name.to_s
|
|
102
104
|
def api_key(value) = @runtime[:api_key] = value.to_s
|
|
103
105
|
def api_base(value) = @runtime[:api_base] = value.to_s
|
|
106
|
+
|
|
107
|
+
# Declares an MCP server instance: global to the graph, not
|
|
108
|
+
# any one agent — gated per agent through `tools_allow_groups` on the
|
|
109
|
+
# group `mcp:<name>`, same as any other tool group. `Insika::DSL::Runtime`
|
|
110
|
+
# upserts it into the McpStore at boot; the MOTOR-VS-FORJA rule applies —
|
|
111
|
+
# code is the TEMPLATE (transport/command/args/url/description always
|
|
112
|
+
# follow the DSL), but an operator's own `enabled`/`env`/`headers` edit
|
|
113
|
+
# via Studio/CLI/API, once the instance exists, is never clobbered back.
|
|
114
|
+
# mcp "tavily", transport: :http, url: "https://mcp.tavily.com/mcp",
|
|
115
|
+
# headers: { "Authorization" => "Bearer #{ENV["TAVILY_KEY"]}" }
|
|
116
|
+
# mcp "filesystem", transport: :stdio, command: "npx",
|
|
117
|
+
# args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
|
118
|
+
#
|
|
119
|
+
# A SYSTEM-level declaration (as opposed to inside one member `agent { }`
|
|
120
|
+
# block) has no single agent's config to auto-grant — it does NOT by
|
|
121
|
+
# itself give any agent access. Declare the `mcp` inside the specific
|
|
122
|
+
# `agent { }` block that needs it instead, where `Builder#mcp` auto-adds
|
|
123
|
+
# "mcp:<name>" to THAT agent's `tools_allow_groups` (below).
|
|
124
|
+
def mcp(name, transport: nil, command: nil, args: nil, url: nil,
|
|
125
|
+
headers: nil, env: nil, description: nil, enabled: true)
|
|
126
|
+
n = name.to_s
|
|
127
|
+
raise ArgumentError, "duplicate mcp instance in system: #{n}" if @mcp_instances.any? { |m| m[:name] == n }
|
|
128
|
+
|
|
129
|
+
@mcp_instances << { name: n, transport: transport&.to_s, command: command, args: args,
|
|
130
|
+
url: url, headers: headers, env: env, description: description,
|
|
131
|
+
enabled: enabled }
|
|
132
|
+
n
|
|
133
|
+
end
|
|
104
134
|
end
|
|
105
135
|
|
|
106
136
|
# Collects the declarations and emits a Insika::Pack. Declarations map 1:1 to
|
|
@@ -117,11 +147,12 @@ module Insika
|
|
|
117
147
|
# correct once you restrict tools/skills. Visible in #to_pack — no hidden magic.
|
|
118
148
|
@config[:policies] = %i[tool_allowlist skill_allowlist]
|
|
119
149
|
@runtime = {} # non-pack knobs (llm provider/key/base) consumed by the runtime
|
|
150
|
+
@mcp_instances = []
|
|
120
151
|
end
|
|
121
152
|
|
|
122
153
|
def build(&block)
|
|
123
154
|
instance_eval(&block) if block
|
|
124
|
-
Definition.new(pack: to_pack, runtime: @runtime)
|
|
155
|
+
Definition.new(pack: to_pack, runtime: @runtime, mcp_instances: @mcp_instances)
|
|
125
156
|
end
|
|
126
157
|
|
|
127
158
|
# --- identity & model ------------------------------------------------
|
|
@@ -204,6 +235,34 @@ module Insika
|
|
|
204
235
|
@config[:subagents] = ids.flatten.map(&:to_s)
|
|
205
236
|
end
|
|
206
237
|
|
|
238
|
+
# --- mcp ---------------------------------------------------------------
|
|
239
|
+
# Declares an MCP server instance — see
|
|
240
|
+
# Insika::DSL::SystemBuilder#mcp for the transport/lifecycle doc; identical
|
|
241
|
+
# shape here for a standalone `Insika.agent { … }` script, or one member
|
|
242
|
+
# agent of a system, that wants one.
|
|
243
|
+
#
|
|
244
|
+
# Auto-adds "mcp:<name>" to THIS agent's `tools_allow_groups` — without
|
|
245
|
+
# it, a pack with no `data_tool` gets PackImporter's `tools_allow: []`
|
|
246
|
+
# (isolation default) and no `tools_allow_groups` at all, so
|
|
247
|
+
# Policy::ToolAllowlist#allowed_names resolves an EMPTY allowlist and the
|
|
248
|
+
# agent could never call the MCP tool it just declared (found writing the
|
|
249
|
+
# MCP templates — no existing spec exercised this path end to
|
|
250
|
+
# end). Same "auto-added to the allowlist" contract `data_tool` already
|
|
251
|
+
# gives its own tool name; `deny_tools` has no group-string equivalent
|
|
252
|
+
# yet, so a whole MCP group cannot be denied by name today.
|
|
253
|
+
def mcp(name, transport: nil, command: nil, args: nil, url: nil,
|
|
254
|
+
headers: nil, env: nil, description: nil, enabled: true)
|
|
255
|
+
n = name.to_s
|
|
256
|
+
raise ArgumentError, "duplicate mcp instance in agent: #{n}" if @mcp_instances.any? { |m| m[:name] == n }
|
|
257
|
+
|
|
258
|
+
@mcp_instances << { name: n, transport: transport&.to_s, command: command, args: args,
|
|
259
|
+
url: url, headers: headers, env: env, description: description,
|
|
260
|
+
enabled: enabled }
|
|
261
|
+
group = "mcp:#{n}"
|
|
262
|
+
(@config[:tools_allow_groups] ||= []) << group unless Array(@config[:tools_allow_groups]).include?(group)
|
|
263
|
+
n
|
|
264
|
+
end
|
|
265
|
+
|
|
207
266
|
# --- knobs -----------------------------------------------------------
|
|
208
267
|
def memory(on = true) = @config[:memory] = on
|
|
209
268
|
|
|
@@ -255,6 +314,41 @@ module Insika
|
|
|
255
314
|
# miner: { model: "deepseek-v4-flash", window: { last_sessions: 200 } }
|
|
256
315
|
def harvest(hash) = (@config[:harvest] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
257
316
|
|
|
317
|
+
# The post-turn knowledge declaration: after a turn completes, the
|
|
318
|
+
# engine may extract durable CONCEPTS (facts, procedures, policies,
|
|
319
|
+
# objections) from it and persist them for later turns to retrieve.
|
|
320
|
+
# Pack data — merges, so repeated calls accumulate (like budget).
|
|
321
|
+
# `prompt`/`model` are pack-authored keys the DSL passes through.
|
|
322
|
+
# knowledge extract: true, retrieve: true, types: %w[fact policy]
|
|
323
|
+
def knowledge(hash) = (@config[:knowledge] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
324
|
+
|
|
325
|
+
# A recurring schedule: one declaration per call,
|
|
326
|
+
# named — a turn the ENGINE fires on its own tick, nobody has to
|
|
327
|
+
# remember. `cron` (5 fields) or `every` (plain interval), a tz for
|
|
328
|
+
# cron materialization, the synthetic inbound `message` that kicks each
|
|
329
|
+
# run, a session mode (`new` = a fresh session per run — the report
|
|
330
|
+
# case; `fixed` = one standing session), per-run `overrides`
|
|
331
|
+
# (turn_timeout / max_tool_calls / model) and `enabled`.
|
|
332
|
+
# schedule "daily_report", cron: "0 22 * * *", tz: "America/Sao_Paulo",
|
|
333
|
+
# message: "Run the daily report now.",
|
|
334
|
+
# overrides: { turn_timeout: 900, max_tool_calls: 200 }
|
|
335
|
+
# Distinct by shape from the `schedule_followup` TOOL (a one-shot,
|
|
336
|
+
# customer-facing, consent-gated contact); see docs/SCHEDULING.md.
|
|
337
|
+
def schedule(name, every: nil, cron: nil, tz: nil, message: nil,
|
|
338
|
+
session_mode: nil, session_id: nil, overrides: nil, enabled: nil)
|
|
339
|
+
id = name.to_s.downcase # the engine canonicalizes ids to lowercase
|
|
340
|
+
if Array(@config[:schedules]).any? { |s| s["id"] == id }
|
|
341
|
+
raise ArgumentError, "duplicate schedule in agent: #{id}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
entry = { "id" => id, "every" => every, "cron" => cron, "tz" => tz,
|
|
345
|
+
"message" => message, "session_mode" => session_mode,
|
|
346
|
+
"session_id" => session_id, "overrides" => overrides,
|
|
347
|
+
"enabled" => enabled }.compact
|
|
348
|
+
@config[:schedules] = Array(@config[:schedules]) + [entry]
|
|
349
|
+
id
|
|
350
|
+
end
|
|
351
|
+
|
|
258
352
|
# Provider-interaction reliability, as DATA (WS3): retries + exponential
|
|
259
353
|
# backoff on transient failures, a fallback model chain (mid-turn
|
|
260
354
|
# rotation), and a circuit breaker per (tenant, provider/model) that
|
|
@@ -305,6 +399,13 @@ module Insika
|
|
|
305
399
|
# tts: { model: "tts-1", voice: "alloy" }
|
|
306
400
|
def outputs(hash) = (@config[:outputs] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
307
401
|
|
|
402
|
+
# STT vocabulary hint (WS9): domain words (product names, brand terms)
|
|
403
|
+
# the transcriber should expect on THIS agent's voice notes — passed
|
|
404
|
+
# straight through to the Whisper-family provider's `prompt:`. Falls
|
|
405
|
+
# back to INSIKA_STT_PROMPT (deployment default) when unset.
|
|
406
|
+
# stt_prompt "Ocean Drop, tênis, boné trucker, chinelo"
|
|
407
|
+
def stt_prompt(text) = @config[:stt_prompt] = text.to_s
|
|
408
|
+
|
|
308
409
|
# The engine's "Tool discipline" block in the system prompt (retry a
|
|
309
410
|
# weak/empty tool result with a different approach before giving up).
|
|
310
411
|
# ON by default — this setter exists to turn it OFF:
|
data/lib/insika/env_schema.rb
CHANGED
|
@@ -110,6 +110,9 @@ module Insika
|
|
|
110
110
|
spec(name: "INSIKA_TURN_TIMING", type: :boolean, description: "Emit per-turn TTFB breakdown in responses (opt-in)."),
|
|
111
111
|
spec(name: "INSIKA_SUBAGENT_DEPTH_CAP", type: :integer, description: "Max delegation depth in the subagent graph (default 5)."),
|
|
112
112
|
spec(name: "INSIKA_SUBAGENT_FANOUT_CAP", type: :integer, description: "Max parallel children in spawn_subagents (default 8)."),
|
|
113
|
+
spec(name: "INSIKA_ARTIFACT_SIGNING_KEY", secret: true, description: "HMAC key for signed artifact links (INSIKA_ARTIFACT_SIGNING_TTL). Unset -> no signed artifact surface."),
|
|
114
|
+
spec(name: "INSIKA_ARTIFACT_SIGNING_TTL", type: :integer, description: "Seconds a signed artifact link stays valid (default 604800 = 7 days)."),
|
|
115
|
+
spec(name: "INSIKA_ARTIFACT_MAX_BYTES", type: :integer, description: "Size cap on an artifact's content in bytes (default 1048576)."),
|
|
113
116
|
spec(name: "INSIKA_CONFIG_STRICT", type: :boolean, description: "Refuse boot on any config finding instead of warning."),
|
|
114
117
|
spec(name: "INSIKA_BOOT_ID", description: "Boot generation id shared by all workers of one container start; the recovery task sweep runs once per id. Unset -> every boot sweeps."),
|
|
115
118
|
spec(name: "INSIKA_DRAIN_TIMEOUT", type: :integer, description: "Seconds a stopping worker waits for in-flight turns before abandoning them to the next boot's recovery (default 20)."),
|
|
@@ -117,6 +120,7 @@ module Insika
|
|
|
117
120
|
spec(name: "INSIKA_TICK_STALE_AFTER", type: :integer, description: "Seconds a :queued/:running task must sit untouched before the tick sweeps it (default 900). Must exceed the largest turn_timeout of the deployment."),
|
|
118
121
|
spec(name: "INSIKA_STT_MODEL", description: "Model used to transcribe audio message parts (WS9). Unset -> RubyLLM's default transcription model."),
|
|
119
122
|
spec(name: "INSIKA_STT_LANGUAGE", description: "Language hint for the transcription of audio message parts (WS9)."),
|
|
123
|
+
spec(name: "INSIKA_STT_PROMPT", description: "Deployment-wide vocabulary hint (product names, brand terms) for audio transcription (WS9). Overridden per agent by the profile's stt_prompt."),
|
|
120
124
|
spec(name: "INSIKA_TENANCY", enum: %w[single_tenant multi_tenant], description: "single_tenant (default: one operator credential) or multi_tenant (per-tenant + operator tokens resolved from the store)."),
|
|
121
125
|
spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in)."),
|
|
122
126
|
spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted."),
|
|
@@ -129,9 +133,20 @@ module Insika
|
|
|
129
133
|
spec(name: "INSIKA_HARVEST_NEGATIVE", type: :path, description: "The negative-list seed file the harvest CLI imports into agent profiles."),
|
|
130
134
|
spec(name: "INSIKA_WIDGET_ORIGINS", type: :csv, description: "Exact-match origins allowed to embed the web widget. Unset -> the widget channel is not mounted."),
|
|
131
135
|
spec(name: "INSIKA_WIDGET_AGENTS", type: :csv, description: "Agent ids a widget visitor may address. Unset -> the widget channel is not mounted."),
|
|
136
|
+
spec(name: "INSIKA_MCP_STDIO", type: :boolean, description: "Allow stdio MCP instances to spawn a child process (arbitrary command execution by config). Unset -> stdio instances save but refuse to start."),
|
|
137
|
+
spec(name: "INSIKA_ROUTER_BACKENDS", type: :csv, description: "comma-separated backend URLs for `insika-router` (static discovery, the Railway shape). Exactly one of this or INSIKA_ROUTER_BACKENDS_DNS."),
|
|
138
|
+
spec(name: "INSIKA_ROUTER_BACKENDS_DNS", description: "a headless-Service hostname `insika-router` re-resolves on an interval (the Kubernetes shape). Requires INSIKA_ROUTER_BACKEND_PORT."),
|
|
139
|
+
spec(name: "INSIKA_ROUTER_BACKEND_PORT", type: :integer, description: "the engine port on every DNS-resolved backend pod (required with INSIKA_ROUTER_BACKENDS_DNS)."),
|
|
140
|
+
spec(name: "INSIKA_ROUTER_DNS_INTERVAL", type: :integer, description: "seconds between `insika-router` DNS re-resolves (default 15)."),
|
|
141
|
+
spec(name: "INSIKA_ROUTER_BODY_MAX_BYTES", type: :integer, description: "size cap `insika-router` will parse looking for a session key before falling back to round-robin (default 262144; never bounds what is forwarded)."),
|
|
142
|
+
spec(name: "INSIKA_ROUTER_BACKEND_TIMEOUT", type: :integer, description: "`insika-router`'s connect/read timeout to a backend, in seconds (default 10)."),
|
|
143
|
+
spec(name: "INSIKA_ROUTER_HOST", description: "bind address for `insika-router` itself (default 0.0.0.0)."),
|
|
144
|
+
spec(name: "INSIKA_ROUTER_PORT", type: :integer, description: "listen port for `insika-router` itself (default 9090)."),
|
|
132
145
|
spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
|
|
133
146
|
spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
|
|
134
|
-
spec(name: "
|
|
147
|
+
spec(name: "INSIKA_PLUGIN_DIR", type: :path, description: "Workspace plugin root (directories with insika.plugin.yml). Loaded at boot; ids still need INSIKA_PLUGINS."),
|
|
148
|
+
spec(name: "INSIKA_PLUGINS", type: :csv, description: "Plugin ids to enable from the workspace/bundled roots. Announced gems are enabled by installing them."),
|
|
149
|
+
spec(name: "INSIKA_PLUGINS_DISABLED", type: :csv, description: "Plugin ids that never load — the absolute veto, wins over INSIKA_PLUGINS and over an announced gem."),
|
|
135
150
|
spec(name: "ADMIN_TOKEN", secret: true, description: "Studio login token; unset -> /studio fail-closed."),
|
|
136
151
|
spec(name: "OTEL_SERVICE_NAME", description: "Service name for OTEL spans (default: insika).")
|
|
137
152
|
].freeze
|
data/lib/insika/evals/golden.rb
CHANGED
|
@@ -11,10 +11,26 @@ module Insika
|
|
|
11
11
|
module Evals
|
|
12
12
|
# A curated behavior case, loaded from a data file (evals/golden/<agent>/*.yml).
|
|
13
13
|
# Data, not code — same spirit as tools-as-data. See evals/README.md for the format.
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
#
|
|
15
|
+
# A case is ONE of two shapes: `turns:` (a scripted replay) or
|
|
16
|
+
# `persona:` (a conversation the Simulator GENERATES). A persona
|
|
17
|
+
# case is `simulated?` — the replay Runner skips it, and the Simulator drives it.
|
|
18
|
+
#
|
|
19
|
+
# `tenant` (C3.1): which tenant authored this case — "platform" (the
|
|
20
|
+
# single-tenant default, like `save_artifact`'s own binding_tenant) unless the
|
|
21
|
+
# case declares one. `run_persona_eval` uses it to keep a QA agent from ever
|
|
22
|
+
# running (or even seeing) another tenant's persona case in the same store.
|
|
23
|
+
Golden = Struct.new(:id, :agent, :turns, :expect, :requires, :reference, :source, :persona, :tenant,
|
|
24
|
+
keyword_init: true) do
|
|
25
|
+
# The user messages to replay, in order. Empty for a persona case: a generated
|
|
26
|
+
# conversation has no scripted turns.
|
|
16
27
|
def user_turns = turns.map { |t| t["user"] }
|
|
17
28
|
|
|
29
|
+
# The simulated customer. nil for a scripted case.
|
|
30
|
+
def simulated? = !persona.nil?
|
|
31
|
+
|
|
32
|
+
def opens_with = persona ? persona.opens_with : user_turns.first
|
|
33
|
+
|
|
18
34
|
# Tool refs the case expects; a trailing "?" marks OPTIONAL (never fails).
|
|
19
35
|
# -> [{ name:, optional: }]
|
|
20
36
|
def tools_called
|
|
@@ -85,7 +101,12 @@ module Insika
|
|
|
85
101
|
|
|
86
102
|
id = presence(raw["id"]) || (raise InvalidGolden, "#{source}: 'id' is required")
|
|
87
103
|
agent = presence(raw["agent"]) || (raise InvalidGolden, "#{source}: 'agent' is required (case '#{id}')")
|
|
88
|
-
|
|
104
|
+
persona = normalize_persona(raw["persona"], id: id, source: source)
|
|
105
|
+
if persona && !raw["turns"].nil?
|
|
106
|
+
raise InvalidGolden, "#{source}: a case is ONE shape — 'turns' or 'persona', not both (case '#{id}')"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
turns = persona ? [] : normalize_turns(raw["turns"], id: id, source: source)
|
|
89
110
|
expect = raw["expect"] || {}
|
|
90
111
|
raise InvalidGolden, "#{source}: 'expect' must be a mapping (case '#{id}')" unless expect.is_a?(Hash)
|
|
91
112
|
|
|
@@ -96,9 +117,25 @@ module Insika
|
|
|
96
117
|
end
|
|
97
118
|
|
|
98
119
|
reference = normalize_reference(raw["reference"], id: id, source: source)
|
|
120
|
+
tenant = presence(raw["tenant"]) || "platform"
|
|
99
121
|
|
|
100
122
|
Golden.new(id: id, agent: agent, turns: turns, expect: expect,
|
|
101
|
-
requires: requires, reference: reference, source: source
|
|
123
|
+
requires: requires, reference: reference, source: source, persona: persona,
|
|
124
|
+
tenant: tenant)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# `persona:` is the alternative shape to `turns:`: the
|
|
128
|
+
# conversation is GENERATED, not replayed. Malformed is REFUSED — a persona
|
|
129
|
+
# without `knows` or `max_turns` would simulate nothing. The PersonaLoader
|
|
130
|
+
# already prefixes its messages with the source path; the case id is added
|
|
131
|
+
# ONCE here (the loader is shared by the persona-file CLI, which has no case
|
|
132
|
+
# shape).
|
|
133
|
+
def normalize_persona(raw, id:, source:)
|
|
134
|
+
return nil if raw.nil?
|
|
135
|
+
|
|
136
|
+
PersonaLoader.build(raw, source: source)
|
|
137
|
+
rescue PersonaLoader::InvalidPersona => e
|
|
138
|
+
raise InvalidGolden, "#{e.message} (case '#{id}')"
|
|
102
139
|
end
|
|
103
140
|
|
|
104
141
|
# reference: { "source" => String?, "messages" => [{ "role" =>, "text" =>,
|
data/lib/insika/evals/judge.rb
CHANGED
|
@@ -67,7 +67,28 @@ module Insika
|
|
|
67
67
|
return nil if rubric.empty?
|
|
68
68
|
|
|
69
69
|
prompt = build_prompt(rubric, golden.user_turns, result.output_text.to_s, golden.policy)
|
|
70
|
-
|
|
70
|
+
score_prompt(prompt, golden.min_score || DEFAULT_MIN_SCORE)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A whole CONVERSATION (a simulated run) scored against the
|
|
74
|
+
# rubric — the transcript, not its last turn. `transcript` is
|
|
75
|
+
# [{ role: "user"|"assistant", text: }] in order. -> Verdict, or nil without
|
|
76
|
+
# a rubric. Same panel, same rules as `score`: the rubric that scores a
|
|
77
|
+
# scripted replay scores a generated one.
|
|
78
|
+
def score_conversation(rubric:, transcript:, policy: nil, min_score: DEFAULT_MIN_SCORE)
|
|
79
|
+
rubric = rubric.to_s.strip
|
|
80
|
+
return nil if rubric.empty?
|
|
81
|
+
|
|
82
|
+
prompt = conversation_prompt(rubric, transcript, policy)
|
|
83
|
+
score_prompt(prompt, min_score)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# The panel machinery shared by `score` (a single reply) and
|
|
89
|
+
# `score_conversation` (a generated transcript): ask each judge, combine,
|
|
90
|
+
# decide by min_agreement. -> Verdict.
|
|
91
|
+
def score_prompt(prompt, min)
|
|
71
92
|
panel = @asks.map { |ask| judge_once(ask, prompt, min) }
|
|
72
93
|
|
|
73
94
|
agreed = panel.count { |j| j[:pass] }
|
|
@@ -77,7 +98,31 @@ module Insika
|
|
|
77
98
|
judges: panel.map { |j| j[:score] })
|
|
78
99
|
end
|
|
79
100
|
|
|
80
|
-
|
|
101
|
+
# The judge reads the FULL conversation as the customer lived it — every
|
|
102
|
+
# user turn and every assistant reply interleaved. A rubric about the whole
|
|
103
|
+
# exchange ("does it discover the objective before recommending?") is
|
|
104
|
+
# unanswerable from the last reply alone.
|
|
105
|
+
def conversation_prompt(rubric, transcript, policy)
|
|
106
|
+
lines = Array(transcript).map do |m|
|
|
107
|
+
role = m[:role].to_s == "user" ? "customer" : "assistant"
|
|
108
|
+
"#{role}: #{m[:text].to_s.strip}"
|
|
109
|
+
end.join("\n")
|
|
110
|
+
<<~PROMPT
|
|
111
|
+
You are a strict QA judge for a customer-service AI assistant. Judge the
|
|
112
|
+
ASSISTANT'S HANDLING OF THE WHOLE CONVERSATION against the RUBRIC — nothing
|
|
113
|
+
else.
|
|
114
|
+
|
|
115
|
+
RUBRIC:
|
|
116
|
+
#{rubric}
|
|
117
|
+
#{policy_clause(policy)}
|
|
118
|
+
CONVERSATION (in order):
|
|
119
|
+
#{lines}
|
|
120
|
+
|
|
121
|
+
Score from 0.0 (fails the rubric) to 1.0 (fully meets it). Respond with ONLY a
|
|
122
|
+
JSON object, no prose:
|
|
123
|
+
{"score": <0..1>, "reason": "<one short sentence>"}
|
|
124
|
+
PROMPT
|
|
125
|
+
end
|
|
81
126
|
|
|
82
127
|
# One model's verdict: its own samples, its own median, its own pass/fail.
|
|
83
128
|
def judge_once(ask, prompt, min)
|
|
@@ -84,6 +84,17 @@ module Insika
|
|
|
84
84
|
end.flatten.join("\n")
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
# A GENERATED (simulated) conversation as the judge reads it: the transcript
|
|
88
|
+
# is already interleaved [{ role: "user"|"assistant", text: }] — map it to the
|
|
89
|
+
# same customer/assistant lines a replay produces, so pairwise compares like
|
|
90
|
+
# for like (a simulated run can be compared against the incumbent).
|
|
91
|
+
def self.transcript_text(messages)
|
|
92
|
+
Array(messages).map do |m|
|
|
93
|
+
role = m[:role].to_s == "user" ? "customer" : "assistant"
|
|
94
|
+
"#{role}: #{m[:text].to_s.strip}"
|
|
95
|
+
end.join("\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
87
98
|
# The incumbent's half. Human turns are NOT flagged to the judge: what it grades
|
|
88
99
|
# is the conversation as the customer received it, and telling it "a person wrote
|
|
89
100
|
# this one" is an invitation to grade the author instead. The fact is carried to
|