insika 0.2.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 +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -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 +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- 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 +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -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 +107 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- 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/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- 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/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +17 -2
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- 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 +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +316 -0
- 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/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- 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/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +16 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -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 +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +6 -3
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +24 -10
- data/lib/insika/task_store.rb +21 -1
- 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/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +89 -0
- 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/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
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
|
|
|
@@ -215,6 +274,81 @@ module Insika
|
|
|
215
274
|
# budget daily: 100_000, monthly: 2_000_000, soft: false
|
|
216
275
|
def budget(hash) = (@config[:budget] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
217
276
|
|
|
277
|
+
# The outcome funnel declaration: the store's stage vocabulary
|
|
278
|
+
# as pack data — the engine folds WS7 outcome kinds into the DECLARED
|
|
279
|
+
# stages, and never hard-codes one itself. Merges, so repeated calls
|
|
280
|
+
# accumulate (like budget).
|
|
281
|
+
# funnel stages: %w[greeted qualified cart paid],
|
|
282
|
+
# advance_on: { "pix_paid" => "paid", "abandoned_cart" => "cart" },
|
|
283
|
+
# primary: "paid", attribution_window: "72h"
|
|
284
|
+
def funnel(hash) = (@config[:funnel] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
285
|
+
|
|
286
|
+
# The follow-up declaration: the agent may book a follow-up
|
|
287
|
+
# with a customer at a future time (`schedule` tool); the engine fires the
|
|
288
|
+
# synthetic turn and enforces the policy (quiet hours, max frequency,
|
|
289
|
+
# cancellation keywords, silence detection) at fire time. Pack data —
|
|
290
|
+
# merges, so repeated calls accumulate (like budget).
|
|
291
|
+
# followup arm: "schedule",
|
|
292
|
+
# policy: { quiet_hours: { timezone: "America/Sao_Paulo",
|
|
293
|
+
# start: "21:30", end: "09:00" },
|
|
294
|
+
# max_frequency: "2/24h",
|
|
295
|
+
# cancel_keywords: ["não quero mais contato"],
|
|
296
|
+
# silence_after_sends: 3 }
|
|
297
|
+
def followup(hash) = (@config[:followup] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
298
|
+
|
|
299
|
+
# The session-distillation declaration: what counts as a fact
|
|
300
|
+
# for THIS store, distilled from finished customer conversations. Pack
|
|
301
|
+
# data — merges, so repeated calls accumulate (like budget). `prompt` and
|
|
302
|
+
# `model` are pack-authored keys the DSL passes through.
|
|
303
|
+
# distill enabled: true, idle_hours: 6, max_proposals: 10
|
|
304
|
+
def distill(hash) = (@config[:distill] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
305
|
+
|
|
306
|
+
# The gated-harvest declaration: the engine may read this
|
|
307
|
+
# agent's finished traffic and propose SKILLS for the store's playbook —
|
|
308
|
+
# through the negative list, the grounding filter and the double gate,
|
|
309
|
+
# never applied automatically. Pack data — merges, so repeated calls
|
|
310
|
+
# accumulate (like budget). `prompt`/`model` are pack-authored keys the
|
|
311
|
+
# DSL passes through.
|
|
312
|
+
# harvest enabled: true,
|
|
313
|
+
# negative_list: [ { rule: "no-competitor-prices", pattern: "concorrente" } ],
|
|
314
|
+
# miner: { model: "deepseek-v4-flash", window: { last_sessions: 200 } }
|
|
315
|
+
def harvest(hash) = (@config[:harvest] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
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
|
+
|
|
218
352
|
# Provider-interaction reliability, as DATA (WS3): retries + exponential
|
|
219
353
|
# backoff on transient failures, a fallback model chain (mid-turn
|
|
220
354
|
# rotation), and a circuit breaker per (tenant, provider/model) that
|
|
@@ -226,17 +360,58 @@ module Insika
|
|
|
226
360
|
(@config[:reliability] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
227
361
|
end
|
|
228
362
|
|
|
229
|
-
|
|
230
|
-
# breaker_open / delivery_failed events to the webhook
|
|
231
|
-
# alerts webhook: "https://ops.example.com/insika-alerts"
|
|
363
|
+
# Operator alert delivery (WS6): POST this agent's budget_warning /
|
|
364
|
+
# breaker_open / delivery_failed events to the webhook as JSON.
|
|
232
365
|
def alerts(hash) = (@config[:alerts] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
233
366
|
|
|
367
|
+
# Intent routing (WS4): classify each turn's message into one route with a
|
|
368
|
+
# cheap model BEFORE the ask. A Hash: route name -> description (or a Hash
|
|
369
|
+
# with description/delegate/stuck/message), plus the reserved keys
|
|
370
|
+
# "default" (the deterministic fallback) and "model"/"provider" (the cheap
|
|
371
|
+
# classifier). The classifier prompt is generated — data only.
|
|
372
|
+
# routes "shopping" => "the customer wants to browse products",
|
|
373
|
+
# "order" => { "description" => "asks about an existing order",
|
|
374
|
+
# "delegate" => "order-agent" },
|
|
375
|
+
# "human" => { "description" => "the customer asks for a person",
|
|
376
|
+
# "stuck" => true },
|
|
377
|
+
# "default" => "shopping", "model" => "deepseek-v4-flash"
|
|
378
|
+
def routes(hash) = (@config[:routes] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
379
|
+
|
|
234
380
|
# The agent may signal it cannot proceed (WS5): when on, the model
|
|
235
381
|
# can call `signal_stuck`, which ends the turn with `outcome: :stuck` + a final
|
|
236
382
|
# message + a `:turn_stuck` event. What "stuck" means is the consumer's call.
|
|
237
383
|
# stuck_signal true
|
|
238
384
|
def stuck_signal(on = true) = @config[:stuck_signal] = on
|
|
239
385
|
|
|
386
|
+
# The per-session working-state schema this agent keeps and asks for
|
|
387
|
+
# a flat list of field names. [] = off (no provider output,
|
|
388
|
+
# no update_briefing/set_next_step tools).
|
|
389
|
+
# briefing_fields "size", "budget", "delivery_day"
|
|
390
|
+
def briefing_fields(*names)
|
|
391
|
+
@config[:briefing_fields] = names.flatten.map(&:to_s)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Generated-media output policy (WS9, saída): the media kinds this
|
|
395
|
+
# agent MAY generate as turn outputs, with per-kind config. The other
|
|
396
|
+
# half of the gate is the CHANNEL's: the request must declare the
|
|
397
|
+
# matching capability for the tools to exist at all.
|
|
398
|
+
# outputs image: { model: "gpt-image-1", size: "1024x1024" },
|
|
399
|
+
# tts: { model: "tts-1", voice: "alloy" }
|
|
400
|
+
def outputs(hash) = (@config[:outputs] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
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
|
+
|
|
409
|
+
# The engine's "Tool discipline" block in the system prompt (retry a
|
|
410
|
+
# weak/empty tool result with a different approach before giving up).
|
|
411
|
+
# ON by default — this setter exists to turn it OFF:
|
|
412
|
+
# tool_persistence false
|
|
413
|
+
def tool_persistence(on = true) = @config[:tool_persistence] = on
|
|
414
|
+
|
|
240
415
|
# Mechanical tool-result dedupe in the replayed history
|
|
241
416
|
# (no-LLM compaction, apt for bloated transcripts). CHANGES WHAT THE MODEL
|
|
242
417
|
# SEES: repeated identical tool results collapse to a back-reference.
|
|
@@ -250,6 +425,12 @@ module Insika
|
|
|
250
425
|
# responses: { "injection" => "I can't help with that." }
|
|
251
426
|
def guardrails(hash) = (@config[:guardrails] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
252
427
|
|
|
428
|
+
# Evidence-grounding policy: the pack declares how the engine
|
|
429
|
+
# polices product claims against the evidence ledger. Same
|
|
430
|
+
# config-over-code shape as `guardrails`; absent = off (parity).
|
|
431
|
+
# grounding mode: :flag, matcher: { sku: '\b[A-Z]{2,4}\d{4,8}\b' }
|
|
432
|
+
def grounding(hash = {}) = (@config[:grounding] ||= {}).merge!(hash.transform_keys(&:to_s))
|
|
433
|
+
|
|
253
434
|
# Refinement — how the agent's own instruction files may be
|
|
254
435
|
# improved from real traffic. Same config-over-code shape as `guardrails`;
|
|
255
436
|
# omitting it entirely leaves the agent report-only (writes nothing).
|
data/lib/insika/edge_limiter.rb
CHANGED
|
@@ -65,8 +65,14 @@ module Insika
|
|
|
65
65
|
# the rate-limit reply exactly when the window is saturated. Entry checks
|
|
66
66
|
# are skipped; the turn's usage still lands on the ledger below.
|
|
67
67
|
resumed = state.resumed
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
# a SCHEDULED turn (the FollowupEngine's kick) skips the
|
|
69
|
+
# ENTRY checks exactly like a resume — a follow-up that trips the token
|
|
70
|
+
# ceiling must not receive the rate-limit REPLY (the customer agreed to
|
|
71
|
+
# this message; the volume control is the follow-up policy, not the flood
|
|
72
|
+
# rail). The turn still runs and its usage still lands on the ledger.
|
|
73
|
+
scheduled = scheduled_turn?(state)
|
|
74
|
+
|
|
75
|
+
if !resumed && !scheduled && (limit = positive(limits.key?(:chat_rate_limit) ? limits[:chat_rate_limit] : edge["chat_rate_limit"]))
|
|
70
76
|
breach = check_chat_rate(state, limit, edge)
|
|
71
77
|
return block(state, edge, **breach) if breach
|
|
72
78
|
end
|
|
@@ -75,7 +81,7 @@ module Insika
|
|
|
75
81
|
# `"chat_rate_limit": null`) reads as OFF for that agent, not "inherit".
|
|
76
82
|
if (ceiling = positive(limits.key?(:agent_token_ceiling) ? limits[:agent_token_ceiling] : edge["agent_token_ceiling"]))
|
|
77
83
|
token_window = positive(edge["agent_token_window"]) || DEFAULT_TOKEN_WINDOW
|
|
78
|
-
unless resumed
|
|
84
|
+
unless resumed || scheduled
|
|
79
85
|
spent = @ledger.count(TOKENS_KIND, state.profile.id.to_s, window: token_window)
|
|
80
86
|
if spent >= ceiling
|
|
81
87
|
return block(state, edge, category: :token_ceiling,
|
|
@@ -90,9 +96,11 @@ module Insika
|
|
|
90
96
|
# typed error (never a customer-facing reply); the alert_at warning and
|
|
91
97
|
# the SOFT over-cap both warn once per window + inject a context note.
|
|
92
98
|
# A resumed turn (crash/pause replay) was already admitted: it is never
|
|
93
|
-
# refused twice — its spend still lands on the ledger below.
|
|
99
|
+
# refused twice — its spend still lands on the ledger below. A scheduled
|
|
100
|
+
# turn rides the same rule: the follow-up policy is the
|
|
101
|
+
# volume control, not the budget wall.
|
|
94
102
|
budget_on = budget_configured?(state)
|
|
95
|
-
budget_enforce(state) unless resumed
|
|
103
|
+
budget_enforce(state) unless resumed || scheduled
|
|
96
104
|
|
|
97
105
|
result = begin
|
|
98
106
|
nxt.call(state)
|
|
@@ -110,6 +118,14 @@ module Insika
|
|
|
110
118
|
|
|
111
119
|
private
|
|
112
120
|
|
|
121
|
+
# is this turn the FollowupEngine's synthetic kick? The
|
|
122
|
+
# command type is stamped by the engine only — a consumer cannot send it
|
|
123
|
+
# (the SendMessage edge refuses the spelling, C8).
|
|
124
|
+
def scheduled_turn?(state)
|
|
125
|
+
command = state.respond_to?(:task) && state.task&.command
|
|
126
|
+
command.is_a?(Hash) && command["type"].to_s == "scheduled_followup"
|
|
127
|
+
end
|
|
128
|
+
|
|
113
129
|
# One KV get per turn (same order of cost as the guardrail's config read);
|
|
114
130
|
# no SettingsStore in the wiring -> per-agent limits only.
|
|
115
131
|
def platform_edge
|
|
@@ -231,12 +247,17 @@ module Insika
|
|
|
231
247
|
return if @budget_ledger.mark_alert(tenant: tenant, agent: agent, window: w[:window],
|
|
232
248
|
level: level, now: now)
|
|
233
249
|
|
|
250
|
+
# `tenant` on the META too, not only in the payload: the tenant-scoped
|
|
251
|
+
# /v1/events subscription filters on meta[:tenant] and is fail-closed, so
|
|
252
|
+
# a warning about the tenant's OWN budget never reached the tenant.
|
|
253
|
+
meta = { task_id: state.task&.id, session_id: state.task&.session_id,
|
|
254
|
+
at: Time.now.utc.iso8601 }
|
|
255
|
+
meta[:tenant] = tenant unless tenant.nil?
|
|
234
256
|
@event_stream&.emit(Insika::Event.new(
|
|
235
257
|
type: :budget_warning,
|
|
236
258
|
data: { agent: agent, tenant: tenant, window: w[:window],
|
|
237
259
|
spent: spent, cap: w[:cap], level: level },
|
|
238
|
-
meta:
|
|
239
|
-
at: Time.now.utc.iso8601 }
|
|
260
|
+
meta: meta
|
|
240
261
|
))
|
|
241
262
|
end
|
|
242
263
|
|
data/lib/insika/env_schema.rb
CHANGED
|
@@ -102,29 +102,51 @@ module Insika
|
|
|
102
102
|
spec(name: "INSIKA_ENV", description: "Environment name shown in the Studio (falls back to RACK_ENV)."),
|
|
103
103
|
spec(name: "INSIKA_A2A_AGENT", description: "Agent id to expose over inbound A2A (opt-in)."),
|
|
104
104
|
spec(name: "INSIKA_A2A_REMOTES", type: :csv, description: "Comma-separated remote A2A endpoints."),
|
|
105
|
-
spec(name: "INSIKA_EGRESS_ALLOW_HTTP", type: :boolean, description: "Allow plain http egress from data-tools (default: https only)."),
|
|
105
|
+
spec(name: "INSIKA_EGRESS_ALLOW_HTTP", type: :boolean, description: "Allow plain http egress from data-tools, channel callbacks and media fetches (default: https only)."),
|
|
106
106
|
spec(name: "INSIKA_EGRESS_ALLOW_PRIVATE", type: :boolean, description: "Allow egress to private/loopback ranges (SSRF guard off)."),
|
|
107
|
-
spec(name: "INSIKA_EGRESS_HOSTS", type: :csv, description: "Comma-separated host allowlist for data-tool egress."),
|
|
107
|
+
spec(name: "INSIKA_EGRESS_HOSTS", type: :csv, description: "Comma-separated host allowlist for data-tool egress (media fetches are NOT pinned by it)."),
|
|
108
108
|
spec(name: "INSIKA_OTEL", type: :boolean, description: "Turn on OpenTelemetry export (opt-in)."),
|
|
109
109
|
spec(name: "INSIKA_MODEL_PRICING", description: "JSON rates table (USD per million tokens) for the estimated-cost attribute; unset -> no cost reported."),
|
|
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)."),
|
|
116
119
|
spec(name: "INSIKA_TICK_INTERVAL", type: :integer, description: "Seconds between tick passes (outbox drain + stale recovery sweep). Default 60; 0 disables."),
|
|
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."),
|
|
121
|
+
spec(name: "INSIKA_STT_MODEL", description: "Model used to transcribe audio message parts (WS9). Unset -> RubyLLM's default transcription model."),
|
|
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."),
|
|
118
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)."),
|
|
119
125
|
spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in)."),
|
|
120
126
|
spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted."),
|
|
121
127
|
spec(name: "INSIKA_RELAY_DELIVER_URL", type: :url, description: "Consumer callback the relay POSTs each reply to."),
|
|
122
128
|
spec(name: "INSIKA_RELAY_DELIVER_TOKEN", secret: true, description: "Bearer the relay sends TO the consumer's callback (optional)."),
|
|
129
|
+
spec(name: "INSIKA_RELAY_SHADOW", type: :boolean, description: "Shadow mode: the relay records replies instead of delivering them."),
|
|
130
|
+
spec(name: "INSIKA_RELAY_DELIVERY", type: :enum, enum: %w[at_end progressive], description: "How the relay flushes the outbox: at_end (one POST) or progressive (one POST per balloon). Unset -> at_end."),
|
|
131
|
+
spec(name: "INSIKA_PARITY_CRITERION", type: :path, description: "The frozen parity criterion file (required in shadow mode)."),
|
|
132
|
+
spec(name: "INSIKA_HARVEST_CRITERION", type: :path, description: "The frozen harvest conversion criterion file (strict-loaded before any promotion)."),
|
|
133
|
+
spec(name: "INSIKA_HARVEST_NEGATIVE", type: :path, description: "The negative-list seed file the harvest CLI imports into agent profiles."),
|
|
123
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."),
|
|
124
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)."),
|
|
125
145
|
spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
|
|
126
146
|
spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
|
|
127
|
-
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."),
|
|
128
150
|
spec(name: "ADMIN_TOKEN", secret: true, description: "Studio login token; unset -> /studio fail-closed."),
|
|
129
151
|
spec(name: "OTEL_SERVICE_NAME", description: "Service name for OTEL spans (default: insika).")
|
|
130
152
|
].freeze
|
data/lib/insika/errors.rb
CHANGED
|
@@ -182,6 +182,17 @@ module Insika
|
|
|
182
182
|
# tell "the recipient refused" from "the engine has a bug".
|
|
183
183
|
class DeliveryError < Error; end
|
|
184
184
|
|
|
185
|
+
# WS4 routing failed: a route's delegate agent is not configured, or its turn
|
|
186
|
+
# failed. An operator/config error — the envelope names the :routing stage
|
|
187
|
+
# instead of swallowing it as :unknown.
|
|
188
|
+
class RoutingError < Error; end
|
|
189
|
+
|
|
190
|
+
# WS9 media failed: an audio part could not be fetched or transcribed, an
|
|
191
|
+
# image attachment could not be built, or a media URL was egress-blocked. A
|
|
192
|
+
# customer's voice message that never entered the turn must not be silently
|
|
193
|
+
# dropped — the :media stage names it.
|
|
194
|
+
class MediaError < Error; end
|
|
195
|
+
|
|
185
196
|
# Strict configuration violation (— OpenClaw's config discipline:
|
|
186
197
|
# "recusa boot com chave desconhecida, no silent config compat"). Raised by
|
|
187
198
|
# EnvSchema.enforce! at boot ONLY when strictness is on (INSIKA_CONFIG_STRICT) —
|
|
@@ -59,8 +59,9 @@ module Insika
|
|
|
59
59
|
module Assertions
|
|
60
60
|
# Named negative detectors for `must_not` now live in the runtime. Kept as
|
|
61
61
|
# an alias so any external reference to Evals::Assertions::PII_DETECTORS still
|
|
62
|
-
# resolves; the values ARE the runtime's, never a fork.
|
|
63
|
-
|
|
62
|
+
# resolves; the values ARE the runtime's, never a fork. the
|
|
63
|
+
# pattern data moved to the corpus, still under the same Safety umbrella.
|
|
64
|
+
PII_DETECTORS = Insika::Safety::Corpus::PII
|
|
64
65
|
|
|
65
66
|
# HOW MUCH THE AGENT SHOULD ASK BEFORE ACTING. Declared per
|
|
66
67
|
# case because it is a per-STORE decision, not a universal rule: sometimes the
|
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)
|
|
@@ -60,12 +60,19 @@ module Insika
|
|
|
60
60
|
def compare(golden:, turns:)
|
|
61
61
|
return nil unless golden.reference?
|
|
62
62
|
|
|
63
|
-
ours
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
compare_texts(ours: Pairwise.transcript(golden.user_turns, turns),
|
|
64
|
+
theirs: Pairwise.reference_transcript(golden.reference_messages),
|
|
65
|
+
vs: golden.human_assisted? ? "human-assisted" : "agent")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# two transcripts, no golden — the shadow seam. The judge is
|
|
69
|
+
# the SAME object with the SAME prompt, so shadow pairs and golden cases are
|
|
70
|
+
# graded by one rule. An empty side returns nil (never a verdict against an
|
|
71
|
+
# empty string — that was a bug in the golden path until this seam landed).
|
|
72
|
+
def compare_texts(ours:, theirs:, vs: "agent")
|
|
73
|
+
return nil if ours.to_s.strip.empty? || theirs.to_s.strip.empty?
|
|
66
74
|
|
|
67
|
-
|
|
68
|
-
combine(panel, vs: golden.human_assisted? ? "human-assisted" : "agent")
|
|
75
|
+
combine(@asks.map { |ask| judge_once(ask, ours, theirs) }, vs: vs)
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
# The replayed conversation as the judge reads it: the user turns we sent,
|
|
@@ -77,12 +84,28 @@ module Insika
|
|
|
77
84
|
end.flatten.join("\n")
|
|
78
85
|
end
|
|
79
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
|
+
|
|
80
98
|
# The incumbent's half. Human turns are NOT flagged to the judge: what it grades
|
|
81
99
|
# is the conversation as the customer received it, and telling it "a person wrote
|
|
82
100
|
# this one" is an invitation to grade the author instead. The fact is carried to
|
|
83
101
|
# the READER as `vs: human-assisted` instead, which is where it changes a decision.
|
|
102
|
+
# A reference with no text at all returns "" — the caller's empty guard then
|
|
103
|
+
# refuses instead of judging against an empty string .
|
|
84
104
|
def self.reference_transcript(messages)
|
|
85
|
-
Array(messages)
|
|
105
|
+
msgs = Array(messages)
|
|
106
|
+
return "" if msgs.all? { |m| m["text"].to_s.strip.empty? }
|
|
107
|
+
|
|
108
|
+
msgs.map do |m|
|
|
86
109
|
speaker = m["role"].to_s == "user" ? "customer" : "assistant"
|
|
87
110
|
"#{speaker}: #{m['text'].to_s.strip}"
|
|
88
111
|
end.join("\n")
|