insika 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# `insika doctor` (— OpenClaw's "strict config + doctor --fix",
|
|
5
|
+
# "the repo's best productization discipline"). A read-only diagnosis of a
|
|
6
|
+
# deployment's configuration that turns the scattered, silent boot warnings into
|
|
7
|
+
# ONE structured report — and, with `--fix`, applies the safe autofixes.
|
|
8
|
+
#
|
|
9
|
+
# Every check is DATA (config over convention): an id, a title, and a lambda that
|
|
10
|
+
# inspects the injected collaborators and returns Findings. A Finding may carry a
|
|
11
|
+
# `fix` proc; `fix!` runs those and re-diagnoses. Collaborators are optional so the
|
|
12
|
+
# CLI can build them straight off the durable backend (no full app boot / no
|
|
13
|
+
# DEEPSEEK) and an env-only caller can skip the store checks entirely.
|
|
14
|
+
class Doctor
|
|
15
|
+
# severity: :ok (green) | :info | :warn | :error. `fix` = a 0-arg proc or nil.
|
|
16
|
+
Finding = Data.define(:check, :severity, :message, :fix) do
|
|
17
|
+
def fixable? = !fix.nil?
|
|
18
|
+
def ok? = severity == :ok
|
|
19
|
+
def error? = severity == :error
|
|
20
|
+
def to_h = { "check" => check, "severity" => severity.to_s, "message" => message, "fixable" => fixable? }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The outcome of a run: the findings + convenience rollups + renderers.
|
|
24
|
+
Report = Data.define(:findings) do
|
|
25
|
+
def ok? = errors.empty?
|
|
26
|
+
def errors = findings.select(&:error?)
|
|
27
|
+
def warnings = findings.select { |f| f.severity == :warn }
|
|
28
|
+
def fixable = findings.select(&:fixable?)
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{ "ok" => ok?, "counts" => counts, "findings" => findings.map(&:to_h) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def counts
|
|
35
|
+
findings.each_with_object(Hash.new(0)) { |f, acc| acc[f.severity.to_s] += 1 }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Human report. `color` wraps severities in ANSI when the sink is a TTY.
|
|
39
|
+
def to_s(color: false)
|
|
40
|
+
lines = findings.map { |f| "#{glyph(f.severity, color)} [#{f.check}] #{f.message}#{' (fixable)' if f.fixable?}" }
|
|
41
|
+
summary =
|
|
42
|
+
if ok? && warnings.empty? then "config OK"
|
|
43
|
+
elsif ok? then "config OK — #{warnings.length} warning(s)"
|
|
44
|
+
else "#{errors.length} error(s), #{warnings.length} warning(s)"
|
|
45
|
+
end
|
|
46
|
+
(lines + ["", summary]).join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def glyph(severity, color)
|
|
52
|
+
sym = { ok: "✓", info: "•", warn: "!", error: "✗" }.fetch(severity, "•")
|
|
53
|
+
return sym unless color
|
|
54
|
+
|
|
55
|
+
code = { ok: 32, info: 36, warn: 33, error: 31 }.fetch(severity, 0)
|
|
56
|
+
"\e[#{code}m#{sym}\e[0m"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize(env: ENV, settings_store: nil, llm_provider_store: nil, tool_store: nil,
|
|
61
|
+
agent_file_store: nil, skill_store: nil, skill_catalog: nil,
|
|
62
|
+
profile_source: nil, backend: nil, extra_env_specs: [])
|
|
63
|
+
@env = env
|
|
64
|
+
@settings_store = settings_store
|
|
65
|
+
@llm_provider_store = llm_provider_store
|
|
66
|
+
@tool_store = tool_store
|
|
67
|
+
@agent_file_store = agent_file_store
|
|
68
|
+
@skill_store = skill_store
|
|
69
|
+
@skill_catalog = skill_catalog
|
|
70
|
+
@profile_source = profile_source
|
|
71
|
+
@backend = backend
|
|
72
|
+
@extra_env_specs = extra_env_specs
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -> Report. Never raises (a broken check degrades to an :error Finding).
|
|
76
|
+
def run
|
|
77
|
+
Report.new(findings: checks.flat_map { |check| safe(check) }.freeze)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Applies every fixable finding, then re-diagnoses. -> [Report before, Report after].
|
|
81
|
+
def fix!
|
|
82
|
+
before = run
|
|
83
|
+
before.fixable.map(&:fix).each(&:call)
|
|
84
|
+
[before, run]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
|
|
90
|
+
check_admin_token check_data_tools check_prompt_files check_relay_channel
|
|
91
|
+
check_web_widget check_skill_eager check_skill_drift]
|
|
92
|
+
|
|
93
|
+
def safe(check)
|
|
94
|
+
Array(send(check))
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
id = check.to_s.sub(/^check_/, "").tr("_", "-")
|
|
97
|
+
[Finding.new(check: id, severity: :error, message: "check crashed: #{e.class}: #{e.message}", fix: nil)]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# -- checks --------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def check_env
|
|
103
|
+
findings = Insika::EnvSchema.validate(@env, extra: @extra_env_specs)
|
|
104
|
+
return [ok("env", "environment: no unknown/invalid keys in owned namespaces")] if findings.empty?
|
|
105
|
+
|
|
106
|
+
findings.map { |f| Finding.new(check: "env", severity: f.severity, message: f.message, fix: nil) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def check_settings_schema
|
|
110
|
+
return [] unless @settings_store
|
|
111
|
+
|
|
112
|
+
pending = @settings_store.pending_migrations
|
|
113
|
+
return [ok("settings-schema", "settings schema at v#{Insika::SettingsStore::SCHEMA_VERSION}")] if pending.empty?
|
|
114
|
+
|
|
115
|
+
[Finding.new(check: "settings-schema", severity: :warn,
|
|
116
|
+
message: "settings schema at v#{@settings_store.stored_schema_version}, pending migration(s) #{pending.inspect} to v#{Insika::SettingsStore::SCHEMA_VERSION}",
|
|
117
|
+
fix: -> { @settings_store.migrate! })]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def check_default_model
|
|
121
|
+
return [] unless @settings_store
|
|
122
|
+
|
|
123
|
+
settings = @settings_store.get
|
|
124
|
+
return [ok("default-model", "platform default model: #{settings['default_model']}")] if Insika::Coercion.present?(settings["default_model"])
|
|
125
|
+
|
|
126
|
+
seed = seed_model
|
|
127
|
+
fix = seed && -> { @settings_store.update("default_model" => seed) }
|
|
128
|
+
msg = "no platform default_model set (a model-less agent has nothing to resolve; set one in Studio > Settings)"
|
|
129
|
+
msg += " — fixable from #{seed}" if seed
|
|
130
|
+
[Finding.new(check: "default-model", severity: :warn, message: msg, fix: fix)]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def check_db
|
|
134
|
+
unless @backend.is_a?(Insika::Stores::SQLite)
|
|
135
|
+
return [Finding.new(check: "db", severity: :info,
|
|
136
|
+
message: "ephemeral backend (INSIKA_DB unset) — config and state do NOT survive a restart", fix: nil)]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@backend.get("__doctor__", "probe") # a read round-trips the handle; raises if the file is broken
|
|
140
|
+
[ok("db", "durable backend: SQLite (readable)")]
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
[Finding.new(check: "db", severity: :error, message: "SQLite backend not usable: #{e.class}: #{e.message}", fix: nil)]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def check_llm_provider
|
|
146
|
+
return [] unless @llm_provider_store
|
|
147
|
+
|
|
148
|
+
configured = !Array(@llm_provider_store.apis).empty? || Insika::Coercion.present?(@env["DEEPSEEK_API_KEY"])
|
|
149
|
+
return [ok("llm-provider", "LLM provider configured")] if configured
|
|
150
|
+
|
|
151
|
+
[Finding.new(check: "llm-provider", severity: :warn,
|
|
152
|
+
message: "no LLM provider configured (set DEEPSEEK_API_KEY or add one in Studio > LLM providers) — turns will fail", fix: nil)]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def check_admin_token
|
|
156
|
+
return [ok("admin-token", "ADMIN_TOKEN set")] if Insika::Coercion.present?(@env["ADMIN_TOKEN"])
|
|
157
|
+
|
|
158
|
+
[Finding.new(check: "admin-token", severity: :warn,
|
|
159
|
+
message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# A half-configured relay is the silent kind of broken: with only
|
|
163
|
+
# the deliver URL set, nothing is mounted and every inbound POST 404s; with only
|
|
164
|
+
# the token, the engine accepts turns it can never answer and the customer waits
|
|
165
|
+
# forever on a reply that is sitting in the outbox. Both halves or neither.
|
|
166
|
+
def check_relay_channel
|
|
167
|
+
token = Insika::Coercion.present?(@env["INSIKA_RELAY_TOKEN"])
|
|
168
|
+
url = Insika::Coercion.present?(@env["INSIKA_RELAY_DELIVER_URL"])
|
|
169
|
+
return [] unless token || url
|
|
170
|
+
|
|
171
|
+
if token && url
|
|
172
|
+
[ok("relay-channel", "relay channel mounted at /channels/relay/events")]
|
|
173
|
+
elsif token
|
|
174
|
+
[Finding.new(check: "relay-channel", severity: :warn,
|
|
175
|
+
message: "INSIKA_RELAY_TOKEN set without INSIKA_RELAY_DELIVER_URL — inbound is accepted but no reply can be delivered", fix: nil)]
|
|
176
|
+
else
|
|
177
|
+
[Finding.new(check: "relay-channel", severity: :warn,
|
|
178
|
+
message: "INSIKA_RELAY_DELIVER_URL set without INSIKA_RELAY_TOKEN — the relay channel is NOT mounted (the token is the switch)", fix: nil)]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The widget is the one PUBLIC channel, so its misconfigurations are the ones that
|
|
183
|
+
# cost money rather than just failing. Three of them, in the
|
|
184
|
+
# order they bite: half the switch set (mounted nowhere, or mounted addressing
|
|
185
|
+
# nothing), and a mount with no chat rate limit anywhere — which the channel
|
|
186
|
+
# refuses with a 503 rather than opening, but which reads to an operator as "the
|
|
187
|
+
# widget is broken" unless something says why.
|
|
188
|
+
def check_web_widget
|
|
189
|
+
origins = Insika::Coercion.present?(@env["INSIKA_WIDGET_ORIGINS"])
|
|
190
|
+
agents = Insika::Coercion.present?(@env["INSIKA_WIDGET_AGENTS"])
|
|
191
|
+
return [] unless origins || agents
|
|
192
|
+
|
|
193
|
+
unless origins && agents
|
|
194
|
+
missing = origins ? "INSIKA_WIDGET_AGENTS" : "INSIKA_WIDGET_ORIGINS"
|
|
195
|
+
return [Finding.new(check: "web-widget", severity: :warn,
|
|
196
|
+
message: "#{missing} unset — the web widget is NOT mounted (both allowlists are the switch)", fix: nil)]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
return [ok("web-widget", "web widget mounted at /channels/web (origins + agents allowlisted, rate limit configured)")] if platform_chat_rate_limit
|
|
200
|
+
|
|
201
|
+
# No platform default. A per-agent `limits.chat_rate_limit` still satisfies the
|
|
202
|
+
# gate, and the profiles are not readable from here, so this is a warning and
|
|
203
|
+
# not an error — but it is the likeliest reason a widget answers 503.
|
|
204
|
+
[Finding.new(check: "web-widget", severity: :warn,
|
|
205
|
+
message: "no platform edge.chat_rate_limit — the widget answers 503 unless EVERY agent in " \
|
|
206
|
+
"INSIKA_WIDGET_AGENTS sets limits.chat_rate_limit (a public channel with no ceiling is not served)", fix: nil)]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Eagerness moved from the SKILL.md frontmatter to `profile.skills_eager`, because
|
|
210
|
+
# skills are SHARED and a flag on the skill forced one decision onto every agent
|
|
211
|
+
# holding it. The parser now ignores `eager:` — which is the quiet kind of upgrade:
|
|
212
|
+
# nothing crashes, the body simply stops being in the prompt. This check is the
|
|
213
|
+
# only thing that says so. Its sibling half is the opposite mistake: a name in
|
|
214
|
+
# `skills_eager` that the agent's `skills` allowlist does not contain is
|
|
215
|
+
# intersected away at runtime, so the operator's intent evaporates in silence.
|
|
216
|
+
def check_skill_eager
|
|
217
|
+
findings = stale_eager_frontmatter + unreachable_eager_names
|
|
218
|
+
return findings if findings.any?
|
|
219
|
+
return [] unless @skill_store || @skill_catalog || @profile_source
|
|
220
|
+
|
|
221
|
+
[ok("skill-eager", "skill eagerness: per-agent (profile.skills_eager), no stale frontmatter")]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def stale_eager_frontmatter
|
|
225
|
+
skill_sources.filter_map do |name, content|
|
|
226
|
+
next unless frontmatter_of(content).key?("eager")
|
|
227
|
+
|
|
228
|
+
Finding.new(check: "skill-eager", severity: :warn, fix: nil,
|
|
229
|
+
message: "skill '#{name}' still declares `eager:` in its frontmatter — the key is IGNORED. " \
|
|
230
|
+
"Eagerness is per-agent now: put the name in that agent's `skills_eager` " \
|
|
231
|
+
"(Studio > Skills, or `skills_eager \"#{name}\"` in the DSL).")
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def unreachable_eager_names
|
|
236
|
+
return [] unless @profile_source
|
|
237
|
+
|
|
238
|
+
@profile_source.all.flat_map do |profile|
|
|
239
|
+
eager = profile.skills_eager
|
|
240
|
+
next [] unless eager.is_a?(Array)
|
|
241
|
+
next [] if profile.skills.nil? # nil = every skill allowed, so nothing is unreachable
|
|
242
|
+
|
|
243
|
+
allowed = Array(profile.skills).map(&:to_s)
|
|
244
|
+
(eager.map(&:to_s) - allowed).map do |name|
|
|
245
|
+
Finding.new(check: "skill-eager", severity: :warn, fix: nil,
|
|
246
|
+
message: "agent '#{profile.id}' marks skill '#{name}' eager but does not allow it — " \
|
|
247
|
+
"the name is a no-op. Add it to the agent's skills, or drop it from skills_eager.")
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# { name => raw SKILL.md } across BOTH sources the runtime reads: the disk roots
|
|
253
|
+
# (seed, via the catalog) and the authored store (wins — same precedence as
|
|
254
|
+
# SkillCatalog). The checks parse frontmatter the catalog deliberately drops
|
|
255
|
+
# (`eager:`), so they need the raw text; for a disk skill that is the file itself,
|
|
256
|
+
# and a store-overlaid skill carries a sentinel path that is not a file.
|
|
257
|
+
def skill_sources
|
|
258
|
+
@skill_sources ||= disk_skill_sources.merge(@skill_store ? @skill_store.all : {})
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def disk_skill_sources
|
|
262
|
+
return {} unless @skill_catalog
|
|
263
|
+
|
|
264
|
+
@skill_catalog.all.each_with_object({}) do |skill, acc|
|
|
265
|
+
acc[skill.name] = File.read(skill.path, encoding: "UTF-8") if File.file?(skill.path.to_s)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The frontmatter block only; a `eager:` line in the BODY is prose, not config.
|
|
270
|
+
def frontmatter_of(content)
|
|
271
|
+
match = content.to_s.match(/\A---\s*\n(.*?)\n---\s*\n/m)
|
|
272
|
+
match ? Insika::Frontmatter.parse(match[1]) : {}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Drift between a pack's PROSE and the catalog. Three ways it happened on the
|
|
276
|
+
# pilot, all silent, all found by reading a customer conversation afterwards.
|
|
277
|
+
#
|
|
278
|
+
# Every check here takes MECHANICAL inputs only — skill names, allowlists, agent
|
|
279
|
+
# identities. A check that has to parse prose ("this paragraph declares a count of
|
|
280
|
+
# six") false-positives on the first real pack and takes the doctor's credibility
|
|
281
|
+
# with it, which costs more than the drift it caught.
|
|
282
|
+
def check_skill_drift
|
|
283
|
+
return [] unless (@skill_store || @skill_catalog) && @profile_source
|
|
284
|
+
|
|
285
|
+
findings = prompt_files_naming_unallowed_skills + shared_skills_naming_a_holder + broken_companions
|
|
286
|
+
return findings if findings.any?
|
|
287
|
+
|
|
288
|
+
[ok("skill-drift", "skill references: prompt files, shared bodies and companions all consistent")]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# D1 residue. The routing table is GENERATED now (SkillCatalog#format_for_prompt
|
|
292
|
+
# renders each skill with its triggers), so the hand-written companion has no
|
|
293
|
+
# reason to exist — but a pack that still carries one keeps instructing the model
|
|
294
|
+
# about skills the agent cannot load. Skill names are known ids, so this is a grep.
|
|
295
|
+
def prompt_files_naming_unallowed_skills
|
|
296
|
+
return [] unless @agent_file_store
|
|
297
|
+
|
|
298
|
+
catalog = skill_sources.keys
|
|
299
|
+
@profile_source.all.flat_map do |profile|
|
|
300
|
+
next [] if profile.skills.nil? # nil = everything allowed, nothing to be outside of
|
|
301
|
+
|
|
302
|
+
allowed = Array(profile.skills).map(&:to_s)
|
|
303
|
+
orphans = catalog - allowed
|
|
304
|
+
next [] if orphans.empty?
|
|
305
|
+
|
|
306
|
+
@agent_file_store.list(profile.id).flat_map do |file|
|
|
307
|
+
body = @agent_file_store.read(profile.id, file).to_s
|
|
308
|
+
orphans.select { |name| mentions?(body, name) }.map do |name|
|
|
309
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
310
|
+
message: "agent '#{profile.id}' file '#{file}' names skill '#{name}', which is NOT in its " \
|
|
311
|
+
"skills allowlist — the model is being told to use something it cannot load. " \
|
|
312
|
+
"Allow the skill, or drop the reference (the skill table is generated).")
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# D2. A skill in more than one allowlist that names one of its OWN holders in its
|
|
319
|
+
# text is specialized text in shared clothing — the pilot served the Cacau Show
|
|
320
|
+
# agent three shared skills that each said "na Natura". Specialize it per agent
|
|
321
|
+
# (write_skill with `agent:`) instead of leaving one store's policy in a shared body.
|
|
322
|
+
#
|
|
323
|
+
# Merchant vocabulary beyond the holders' identities is deliberately out of scope:
|
|
324
|
+
# there is no mechanical source for it.
|
|
325
|
+
def shared_skills_naming_a_holder
|
|
326
|
+
holders = skill_holders
|
|
327
|
+
specialized = specialized_by
|
|
328
|
+
skill_sources.flat_map do |name, content|
|
|
329
|
+
owners = holders[name].to_a
|
|
330
|
+
next [] if owners.length < 2
|
|
331
|
+
|
|
332
|
+
# Who still READS this shared body: a holder with its own version reads that
|
|
333
|
+
# instead, so it stops being a victim — but its identity in the shared text
|
|
334
|
+
# keeps poisoning whoever is left. Identity comes from ALL holders; only the
|
|
335
|
+
# readers shrink as specializations land, so the finding clears when the last
|
|
336
|
+
# victim stops reading, never merely because the named holder moved out.
|
|
337
|
+
readers = owners - specialized[name].to_a
|
|
338
|
+
owners.flat_map do |owner|
|
|
339
|
+
victims = (readers - [owner]).sort
|
|
340
|
+
next [] if victims.empty?
|
|
341
|
+
|
|
342
|
+
identity_terms(owner).select { |term| mentions?(content, term) }.map do |term|
|
|
343
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
344
|
+
message: "shared skill '#{name}' names '#{term}', the identity of '#{owner}' — " \
|
|
345
|
+
"#{victims.join(', ')} read(s) #{owner}'s text as their own. Specialize " \
|
|
346
|
+
"'#{owner}' if it needs that text, and remove it from the shared body.")
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# D3 residue, the half `companions:` cannot prevent: a body that points at another
|
|
353
|
+
# catalog skill without declaring it (so the pair can still break apart), and a
|
|
354
|
+
# declared companion an agent is not allowed to load (so the pair breaks for THAT
|
|
355
|
+
# agent, silently, since the engine will not widen an allowlist on its own).
|
|
356
|
+
def broken_companions
|
|
357
|
+
names = skill_sources.keys
|
|
358
|
+
undeclared = skill_sources.flat_map do |name, content|
|
|
359
|
+
declared = declared_companions(content)
|
|
360
|
+
(names - [name] - declared).select { |other| mentions?(body_of(content), other) }.map do |other|
|
|
361
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
362
|
+
message: "skill '#{name}' references skill '#{other}' in its body without declaring it a " \
|
|
363
|
+
"companion — the two can arrive apart, and half a recipe is worse than none. " \
|
|
364
|
+
"Add `companions: [#{other}]` to '#{name}'.")
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
undeclared + companions_outside_allowlists
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Same lenient reading as SkillCatalog#parse_list: a YAML list, or the whole value
|
|
371
|
+
# as one comma-separated String when the tolerant parser had to fall back.
|
|
372
|
+
def declared_companions(content)
|
|
373
|
+
raw = frontmatter_of(content)["companions"]
|
|
374
|
+
Array(raw).flat_map { |c| c.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def companions_outside_allowlists
|
|
378
|
+
declared = skill_sources.each_with_object({}) do |(name, content), acc|
|
|
379
|
+
list = declared_companions(content)
|
|
380
|
+
acc[name] = list unless list.empty?
|
|
381
|
+
end
|
|
382
|
+
return [] if declared.empty?
|
|
383
|
+
|
|
384
|
+
@profile_source.all.flat_map do |profile|
|
|
385
|
+
next [] if profile.skills.nil?
|
|
386
|
+
|
|
387
|
+
allowed = Array(profile.skills).map(&:to_s)
|
|
388
|
+
declared.flat_map do |name, companions|
|
|
389
|
+
next [] unless allowed.include?(name)
|
|
390
|
+
|
|
391
|
+
(companions - allowed).map do |missing|
|
|
392
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
393
|
+
message: "agent '#{profile.id}' allows skill '#{name}' but not its companion '#{missing}' — " \
|
|
394
|
+
"the pair cannot travel together for this agent. Allow '#{missing}' too.")
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# { skill name => [agent ids that have their OWN version] }.
|
|
401
|
+
def specialized_by
|
|
402
|
+
return {} unless @skill_store.respond_to?(:agents)
|
|
403
|
+
|
|
404
|
+
@skill_store.agents.each_with_object({}) do |agent, acc|
|
|
405
|
+
@skill_store.names(agent: agent).each { |name| (acc[name] ||= []) << agent }
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# { skill name => Set(agent ids that allow it explicitly) }. An agent with
|
|
410
|
+
# skills=nil allows everything and is not a "holder": it says nothing about which
|
|
411
|
+
# skills were meant to be shared.
|
|
412
|
+
def skill_holders
|
|
413
|
+
@profile_source.all.each_with_object({}) do |profile, acc|
|
|
414
|
+
next if profile.skills.nil?
|
|
415
|
+
|
|
416
|
+
Array(profile.skills).each { |name| (acc[name.to_s] ||= []) << profile.id }
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# An agent's identity as WORDS: the distinctive tokens of its id plus whatever it
|
|
421
|
+
# calls itself in metadata. Structural tokens (agent/store/bot/…) are dropped and
|
|
422
|
+
# short ones ignored — "store" appears in every retail skill ever written, and one
|
|
423
|
+
# false positive is enough for an operator to stop reading the doctor.
|
|
424
|
+
IDENTITY_STOPWORDS = %w[agent agente store shop loja bot assistant assistente atendimento
|
|
425
|
+
prod staging demo test main default].freeze
|
|
426
|
+
|
|
427
|
+
def identity_terms(agent_id)
|
|
428
|
+
profile = @profile_source.fetch(agent_id)
|
|
429
|
+
meta = profile ? (profile.metadata || {}) : {}
|
|
430
|
+
raw = [agent_id.to_s.split(/[-_.\s]+/), meta["name"], meta["display_name"], meta["store_name"]]
|
|
431
|
+
raw.flatten.compact.map { |t| t.to_s.strip }
|
|
432
|
+
.reject { |t| t.length < 4 || IDENTITY_STOPWORDS.include?(t.downcase) }
|
|
433
|
+
.uniq
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Whole-word, case- and accent-insensitive — the same reading the trigger matcher
|
|
437
|
+
# uses, for the same reason: a substring hit inside a longer word is a false
|
|
438
|
+
# positive, and one of those is enough to lose the operator.
|
|
439
|
+
def mentions?(text, term)
|
|
440
|
+
needle = fold(term)
|
|
441
|
+
return false if needle.empty?
|
|
442
|
+
|
|
443
|
+
/(?<![[:alnum:]])#{Regexp.escape(needle)}(?![[:alnum:]])/.match?(fold(text))
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def fold(text) = text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
|
|
447
|
+
|
|
448
|
+
def body_of(content) = content.to_s.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
|
|
449
|
+
|
|
450
|
+
def platform_chat_rate_limit
|
|
451
|
+
value = ((@settings_store&.get || {})["edge"] || {})["chat_rate_limit"]
|
|
452
|
+
value.to_i.positive?
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# A stored data-tool whose definition no longer builds is INVISIBLE at runtime: the
|
|
456
|
+
# overlay warns to stderr and drops it, so the agent simply stops seeing the tool.
|
|
457
|
+
# This check is that drop's only report. The one legacy case is auto-fixable: a
|
|
458
|
+
# flat param typed bare `array`, which used to mean "list of strings" — the fix
|
|
459
|
+
# writes that meaning down as `array:string` and changes nothing at runtime.
|
|
460
|
+
# A prompt file holding the Ruby #inspect of a Hash instead of markdown. The write
|
|
461
|
+
# path refuses this now, but a deployment corrupted BEFORE that guard keeps serving
|
|
462
|
+
# the mangled prompt on every turn, and nothing else would ever say so: the file is
|
|
463
|
+
# present, non-empty, and the agent answers — worse than a crash. Found on the pilot
|
|
464
|
+
# by an `insika refine` report, three weeks after the fact.
|
|
465
|
+
def check_prompt_files
|
|
466
|
+
return [] unless @agent_file_store
|
|
467
|
+
|
|
468
|
+
agents = @agent_file_store.agents
|
|
469
|
+
wrapped = agents.flat_map do |agent|
|
|
470
|
+
@agent_file_store.list(agent).filter_map do |name|
|
|
471
|
+
next unless wrapped_content?(@agent_file_store.read(agent, name))
|
|
472
|
+
|
|
473
|
+
Finding.new(check: "prompt-files", severity: :error, fix: nil,
|
|
474
|
+
message: "agent '#{agent}' file '#{name}' holds a serialized object, not text — " \
|
|
475
|
+
"the model receives `{\"content\" => …}` on one line, escapes and all. " \
|
|
476
|
+
"Recover the markdown from inside the wrapper and write it back.")
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
return wrapped if wrapped.any?
|
|
480
|
+
|
|
481
|
+
total = agents.sum { |a| @agent_file_store.list(a).length }
|
|
482
|
+
[ok("prompt-files", "#{total} prompt file(s) across #{agents.length} agent(s): all text")]
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Cheap and specific: Ruby's inspect of a Hash whose first key is a string. A real
|
|
486
|
+
# prompt does not open with `{"…" =>`.
|
|
487
|
+
def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
488
|
+
|
|
489
|
+
def check_data_tools
|
|
490
|
+
return [] unless @tool_store
|
|
491
|
+
|
|
492
|
+
broken = @tool_store.all_raw.filter_map { |raw| broken_tool(raw) }
|
|
493
|
+
total = @tool_store.names.length
|
|
494
|
+
return [ok("data-tools", "#{total} data tool(s): every definition valid")] if broken.empty?
|
|
495
|
+
|
|
496
|
+
broken
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def broken_tool(raw)
|
|
500
|
+
Insika::ToolDefinition.from_h(raw)
|
|
501
|
+
nil
|
|
502
|
+
rescue Insika::ValidationError => e
|
|
503
|
+
name = raw.is_a?(Hash) ? raw["name"].to_s : ""
|
|
504
|
+
fix = array_sugar_fix(raw)
|
|
505
|
+
message = "tool '#{name}' is dropped from the catalog: #{e.message}"
|
|
506
|
+
# The autofix preserves the OLD behaviour, which is not always the INTENDED one:
|
|
507
|
+
# a list of objects has been reaching the model declared as a list of strings.
|
|
508
|
+
# Say so, so nobody reads a green doctor as "this tool is right".
|
|
509
|
+
message += " — the fix only writes down what it has been sending (a list of strings); " \
|
|
510
|
+
"if the API expects objects, paste the real JSON Schema in Studio instead" if fix
|
|
511
|
+
Finding.new(check: "data-tools", severity: :error, fix: fix, message: message)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# A fix ONLY when spelling the legacy bare `array` explicitly is enough to make the
|
|
515
|
+
# definition build — never a guess at a broken definition we don't understand.
|
|
516
|
+
def array_sugar_fix(raw)
|
|
517
|
+
params = raw.is_a?(Hash) ? raw["parameters"] : nil
|
|
518
|
+
return nil unless params.is_a?(Array) && params.any? { |p| p.is_a?(Hash) && p["type"].to_s == "array" }
|
|
519
|
+
|
|
520
|
+
candidate = raw.merge("parameters" => params.map do |p|
|
|
521
|
+
p.is_a?(Hash) && p["type"].to_s == "array" ? p.merge("type" => "array:string") : p
|
|
522
|
+
end)
|
|
523
|
+
Insika::ToolDefinition.from_h(candidate)
|
|
524
|
+
-> { @tool_store.write(candidate) }
|
|
525
|
+
rescue Insika::ValidationError
|
|
526
|
+
nil
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# -- helpers -------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
def ok(check, message) = Finding.new(check: check, severity: :ok, message: message, fix: nil)
|
|
532
|
+
|
|
533
|
+
# A model to seed the platform default from: DEEPSEEK_MODEL env, else the first
|
|
534
|
+
# configured provider's default. nil when nothing to seed from.
|
|
535
|
+
def seed_model
|
|
536
|
+
Insika::Coercion.presence(@env["DEEPSEEK_MODEL"])
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module DSL
|
|
5
|
+
# The result of `Insika.agent("id") { … }`. Holds the generated Pack and knows
|
|
6
|
+
# how to run it — but stays gem-free until you actually chat/serve/introspect:
|
|
7
|
+
# the runtime (ruby_llm + the HTTP server) is required lazily inside Runtime.
|
|
8
|
+
#
|
|
9
|
+
# Every runtime path goes through the SAME config-over-code import: the pack is
|
|
10
|
+
# imported (create_agent + write_agent_file/skill/data_tool) into a StoredProfileSource,
|
|
11
|
+
# exactly like any other pack. That is what makes `#profile` identical to a
|
|
12
|
+
# hand-written equivalent pack (the parity spec) — there is only one path.
|
|
13
|
+
class Definition
|
|
14
|
+
attr_reader :pack, :runtime_options
|
|
15
|
+
|
|
16
|
+
def initialize(pack:, runtime: {})
|
|
17
|
+
@pack = pack
|
|
18
|
+
@runtime_options = runtime
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def id = pack.config[:id].to_s
|
|
22
|
+
|
|
23
|
+
# The generated portable artifact — hand it to any PackImporter and you get
|
|
24
|
+
# this same agent. This is the DSL's real output.
|
|
25
|
+
def to_pack = @pack
|
|
26
|
+
|
|
27
|
+
# The AgentProfile the engine runs, read back from the store after import —
|
|
28
|
+
# so it is byte-for-byte what a hand-written equivalent pack produces.
|
|
29
|
+
def profile = runtime.profile
|
|
30
|
+
|
|
31
|
+
# One turn, in-process, returns the assistant's text. `session:` threads
|
|
32
|
+
# multi-turn memory (a session is created on first use); omit it for a
|
|
33
|
+
# stateless one-shot. Raises on a failed/cancelled turn.
|
|
34
|
+
def reply(message, session: nil, timeout: nil)
|
|
35
|
+
runtime.chat(message, session_id: session, timeout: timeout)
|
|
36
|
+
end
|
|
37
|
+
alias_method :chat, :reply
|
|
38
|
+
|
|
39
|
+
# Boot the control UI (/studio) + the drop-in API (/v1) — the "1 command"
|
|
40
|
+
# of the quickstart. Blocks (runs the reactor) until interrupted.
|
|
41
|
+
def serve(port: 9292, host: "localhost", token: nil, **opts)
|
|
42
|
+
runtime.serve(port: port, host: host, token: token, **opts)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The live runtime graph (Executor + bus + stores) with the pack imported.
|
|
46
|
+
# Memoized: chat/serve/profile share one graph. Pulls in ruby_llm here.
|
|
47
|
+
def runtime
|
|
48
|
+
@runtime ||= begin
|
|
49
|
+
require_relative "runtime"
|
|
50
|
+
Runtime.new(self)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|