insika 0.0.1 → 0.1.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 +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -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 +362 -0
- data/docs/SKILLS.md +98 -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 +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -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/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -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_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/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/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 +71 -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 +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -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 +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -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 +114 -0
- data/lib/insika/executor.rb +1680 -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/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 +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -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 +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -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/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 +87 -0
- data/lib/insika/safety/moderator.rb +86 -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/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -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 +113 -0
- data/lib/insika/skill_store.rb +79 -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 +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -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 +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -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/token_estimator.rb +16 -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_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 +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -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 +158 -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 +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- metadata +395 -8
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# STRICT config, environment layer (item 23 / §8.1). OpenClaw's config discipline
|
|
5
|
+
# — "recusa boot com chave desconhecida, no silent config compat" — applied to the
|
|
6
|
+
# env vars the engine reads at boot. A declarative registry of the keys the engine
|
|
7
|
+
# OWNS (config over convention: the schema IS data), used two ways:
|
|
8
|
+
#
|
|
9
|
+
# · `validate(env)` — returns structured Findings: a value that fails its type
|
|
10
|
+
# (INSIKA_PORT=abc), an UNKNOWN key inside a namespace the engine owns
|
|
11
|
+
# (INSIKA_EGRES_ALLOW_HTTP — a typo the runtime would otherwise ignore in
|
|
12
|
+
# silence), and a DEPRECATED legacy key still set under the old HARNESS_ prefix.
|
|
13
|
+
# Unknown-key detection is scoped to the OWNED prefixes only, so the platform's
|
|
14
|
+
# own vars (Railway's RAILWAY_*, PORT, PATH, the litestream sidecar's
|
|
15
|
+
# LITESTREAM_*, the deployment's DEEPSEEK_*/ACHEI_*) are never flagged.
|
|
16
|
+
# · `enforce!(strict:)` — the boot gate. WARNS on every finding by default and
|
|
17
|
+
# lets the engine come up (last-known-good — a rotated key or a typo must never
|
|
18
|
+
# take the whole service down, same reasoning as the resilient DEEPSEEK boot);
|
|
19
|
+
# RAISES ConfigError only when strictness is on (INSIKA_CONFIG_STRICT truthy,
|
|
20
|
+
# or `strict: true`).
|
|
21
|
+
#
|
|
22
|
+
# RENAME (env pass 2): the engine's owned prefix is now INSIKA_. The old HARNESS_
|
|
23
|
+
# names still work — `reconcile_legacy!` backfills INSIKA_* from any HARNESS_* alias
|
|
24
|
+
# at boot (new name wins), and `read` gives the same dual-read to the self-contained
|
|
25
|
+
# readers (Telemetry, TurnTiming, SubagentGraph) that receive an env hash directly.
|
|
26
|
+
# A legacy name in use surfaces as a `:deprecated` warning (never fatal) so operators
|
|
27
|
+
# get a clear "rename to INSIKA_*" without a broken boot.
|
|
28
|
+
#
|
|
29
|
+
# Insika::Doctor reuses `validate` for its `env:*` checks; the boot roots call
|
|
30
|
+
# `enforce!`. A deployment layers its own keys on via `extra:` (see
|
|
31
|
+
# config/deployment.rb) so `insika env` and `insika doctor` see the full picture.
|
|
32
|
+
module EnvSchema
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# One env key the engine knows about. `type` drives validation; `secret` masks
|
|
36
|
+
# the value in `insika env`; `enum` restricts allowed values; `required` makes
|
|
37
|
+
# a blank value a finding (engine keys are all optional — deployment resilience —
|
|
38
|
+
# so `required` is used by deployment extras, not the defaults here).
|
|
39
|
+
Spec = Data.define(:name, :type, :secret, :required, :enum, :description) do
|
|
40
|
+
def secret? = secret
|
|
41
|
+
def required? = required
|
|
42
|
+
|
|
43
|
+
# nil/blank when optional -> no finding. Present -> must satisfy the type. ->
|
|
44
|
+
# error string or nil.
|
|
45
|
+
def error_for(raw)
|
|
46
|
+
value = raw.to_s
|
|
47
|
+
return "is required (unset)" if value.strip.empty? && required
|
|
48
|
+
return nil if value.strip.empty?
|
|
49
|
+
|
|
50
|
+
case type
|
|
51
|
+
when :integer
|
|
52
|
+
"must be an integer, got #{value.inspect}" unless value.match?(/\A-?\d+\z/)
|
|
53
|
+
when :boolean
|
|
54
|
+
"must be a boolean (#{BOOLEANS.join('/')}), got #{value.inspect}" unless EnvSchema.boolean?(value)
|
|
55
|
+
when :enum
|
|
56
|
+
"must be one of #{enum.inspect}, got #{value.inspect}" unless Array(enum).include?(value)
|
|
57
|
+
when :url
|
|
58
|
+
"must be an http(s) URL, got #{value.inspect}" unless value.match?(%r{\Ahttps?://\S+\z})
|
|
59
|
+
end # :string / :csv / :path -> any string is valid
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A problem (or an :ok note) about the environment. dry/serializable — the proc-
|
|
64
|
+
# free shape Doctor and `insika env` both render.
|
|
65
|
+
Finding = Data.define(:key, :kind, :severity, :message) do
|
|
66
|
+
def to_h = { "key" => key, "kind" => kind.to_s, "severity" => severity.to_s, "message" => message }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The engine's owned prefix and its deprecated predecessor.
|
|
70
|
+
PREFIX = "INSIKA_"
|
|
71
|
+
LEGACY_PREFIX = "HARNESS_"
|
|
72
|
+
|
|
73
|
+
# Prefixes the engine fully OWNS: an unknown key under one of these is a typo, not
|
|
74
|
+
# a foreign var. INSIKA_ (current) and HARNESS_ (legacy, still honored during the
|
|
75
|
+
# deprecation window). Deliberately NOT OPENCLAW_ (shared with the OpenClaw gateway
|
|
76
|
+
# product, which sets its own OPENCLAW_HOME/_STATE_DIR/… — the engine merely borrows
|
|
77
|
+
# 3 names for interop), nor LITESTREAM_ (the sidecar owns it), nor OTEL_ (the
|
|
78
|
+
# OpenTelemetry SDK owns its env).
|
|
79
|
+
OWNED_PREFIXES = [PREFIX, LEGACY_PREFIX].freeze
|
|
80
|
+
|
|
81
|
+
BOOLEANS = %w[1 0 true false yes no on off].freeze
|
|
82
|
+
|
|
83
|
+
def boolean?(value) = BOOLEANS.include?(value.to_s.strip.downcase)
|
|
84
|
+
|
|
85
|
+
# Truthy per the engine's convention (telemetry/turn_timing agree). The single
|
|
86
|
+
# home for "is this env flag on?".
|
|
87
|
+
def truthy?(value) = %w[1 true yes on].include?(value.to_s.strip.downcase)
|
|
88
|
+
|
|
89
|
+
def present?(value) = !value.nil? && !value.to_s.strip.empty?
|
|
90
|
+
|
|
91
|
+
def spec(name:, type: :string, secret: false, required: false, enum: nil, description: "")
|
|
92
|
+
Spec.new(name: name, type: type, secret: secret, required: required, enum: enum, description: description)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The engine's own keys. Deployment/app keys (DEEPSEEK_*, ACHEI_*, …) are NOT
|
|
96
|
+
# here — a root passes them as `extra:`.
|
|
97
|
+
DEFAULT = [
|
|
98
|
+
spec(name: "INSIKA_DB", type: :path, description: "SQLite path; durable config+state. Unset -> ephemeral memory."),
|
|
99
|
+
spec(name: "INSIKA_BIND", description: "Bind address for the transport server."),
|
|
100
|
+
spec(name: "INSIKA_PORT", type: :integer, description: "Port for the transport server."),
|
|
101
|
+
spec(name: "INSIKA_PUBLIC_URL", type: :url, description: "Public base URL (A2A agent card, links)."),
|
|
102
|
+
spec(name: "INSIKA_ENV", description: "Environment name shown in the Studio (falls back to RACK_ENV)."),
|
|
103
|
+
spec(name: "INSIKA_A2A_AGENT", description: "Agent id to expose over inbound A2A (opt-in)."),
|
|
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)."),
|
|
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."),
|
|
108
|
+
spec(name: "INSIKA_OTEL", type: :boolean, description: "Turn on OpenTelemetry export (opt-in)."),
|
|
109
|
+
spec(name: "INSIKA_MODEL_PRICING", description: "JSON rates table (USD per million tokens) for the estimated-cost attribute; unset -> no cost reported."),
|
|
110
|
+
spec(name: "INSIKA_TURN_TIMING", type: :boolean, description: "Emit per-turn TTFB breakdown in responses (opt-in, item 34)."),
|
|
111
|
+
spec(name: "INSIKA_SUBAGENT_DEPTH_CAP", type: :integer, description: "Max delegation depth in the subagent graph (default 5)."),
|
|
112
|
+
spec(name: "INSIKA_SUBAGENT_FANOUT_CAP", type: :integer, description: "Max parallel children in spawn_subagents (default 8)."),
|
|
113
|
+
spec(name: "INSIKA_CONFIG_STRICT", type: :boolean, description: "Refuse boot on any config finding instead of warning (item 23)."),
|
|
114
|
+
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 (RFC-0016). Unset -> every boot sweeps."),
|
|
115
|
+
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, RFC-0016 A3)."),
|
|
116
|
+
spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in, item 20)."),
|
|
117
|
+
spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted (RFC-0011 §6)."),
|
|
118
|
+
spec(name: "INSIKA_RELAY_DELIVER_URL", type: :url, description: "Consumer callback the relay POSTs each reply to."),
|
|
119
|
+
spec(name: "INSIKA_RELAY_DELIVER_TOKEN", secret: true, description: "Bearer the relay sends TO the consumer's callback (optional)."),
|
|
120
|
+
spec(name: "INSIKA_WIDGET_ORIGINS", type: :csv, description: "Exact-match origins allowed to embed the web widget. Unset -> the widget channel is not mounted (RFC-0011 §5)."),
|
|
121
|
+
spec(name: "INSIKA_WIDGET_AGENTS", type: :csv, description: "Agent ids a widget visitor may address. Unset -> the widget channel is not mounted."),
|
|
122
|
+
spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
|
|
123
|
+
spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
|
|
124
|
+
spec(name: "OPENCLAW_PLUGIN_DIR", type: :path, description: "Directory of plugins to load."),
|
|
125
|
+
spec(name: "ADMIN_TOKEN", secret: true, description: "Studio login token; unset -> /studio fail-closed."),
|
|
126
|
+
spec(name: "OTEL_SERVICE_NAME", description: "Service name for OTEL spans (default: insika).")
|
|
127
|
+
].freeze
|
|
128
|
+
|
|
129
|
+
# -- dual-read (rename compat) -------------------------------------
|
|
130
|
+
|
|
131
|
+
# HARNESS_X -> INSIKA_X for the deprecated alias of a canonical key; nil if `name`
|
|
132
|
+
# is not an INSIKA_ key.
|
|
133
|
+
def legacy_alias(name)
|
|
134
|
+
s = name.to_s
|
|
135
|
+
s.start_with?(PREFIX) ? LEGACY_PREFIX + s[PREFIX.length..] : nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The current (canonical) name for any owned key: HARNESS_X -> INSIKA_X; an INSIKA_
|
|
139
|
+
# key or any non-owned key is returned unchanged.
|
|
140
|
+
def canonical(name)
|
|
141
|
+
s = name.to_s
|
|
142
|
+
s.start_with?(LEGACY_PREFIX) ? PREFIX + s[LEGACY_PREFIX.length..] : s
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def legacy?(name) = name.to_s.start_with?(LEGACY_PREFIX)
|
|
146
|
+
def owned?(name) = OWNED_PREFIXES.any? { |p| name.to_s.start_with?(p) }
|
|
147
|
+
|
|
148
|
+
# Reads a canonical INSIKA_* key, falling back to the deprecated HARNESS_* alias
|
|
149
|
+
# (new name wins). For the self-contained readers that get an env hash directly and
|
|
150
|
+
# so never see `reconcile_legacy!`'s process-wide backfill. -> value | nil.
|
|
151
|
+
def read(canonical_name, env = ENV)
|
|
152
|
+
value = env[canonical_name]
|
|
153
|
+
return value if present?(value)
|
|
154
|
+
|
|
155
|
+
(legacy = legacy_alias(canonical_name)) ? env[legacy] : value
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# BOOT backfill: copies every still-set HARNESS_* var to its INSIKA_* name (new name
|
|
159
|
+
# wins if both are set) so the process ENV speaks the new names before any read.
|
|
160
|
+
# Generic (not schema-bound) so plugin/deployment HARNESS_* keys migrate too. WARNS
|
|
161
|
+
# once with the migrated list. -> [migrated legacy names]. Idempotent.
|
|
162
|
+
def reconcile_legacy!(env = ENV, warn: method(:default_warn))
|
|
163
|
+
migrated = env.keys.map(&:to_s).select { |k| k.start_with?(LEGACY_PREFIX) }.sort.each_with_object([]) do |legacy, acc|
|
|
164
|
+
canonical = PREFIX + legacy[LEGACY_PREFIX.length..]
|
|
165
|
+
next if present?(env[canonical]) # the new name already wins
|
|
166
|
+
next if env[legacy].nil?
|
|
167
|
+
|
|
168
|
+
env[canonical] = env[legacy]
|
|
169
|
+
acc << legacy
|
|
170
|
+
end
|
|
171
|
+
unless migrated.empty?
|
|
172
|
+
warn.call("deprecation: #{migrated.join(', ')} — HARNESS_* env vars are renamed to INSIKA_*; " \
|
|
173
|
+
"honored via the new names this run, please update your environment (legacy names removed in a future release)")
|
|
174
|
+
end
|
|
175
|
+
migrated
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# -- validation ----------------------------------------------------
|
|
179
|
+
|
|
180
|
+
# -> [Finding]. `extra` = deployment/app specs to fold into the known set (and to
|
|
181
|
+
# widen unknown-key detection over their names). Never raises.
|
|
182
|
+
def validate(env = ENV, extra: [])
|
|
183
|
+
specs = index(DEFAULT + Array(extra))
|
|
184
|
+
findings = []
|
|
185
|
+
|
|
186
|
+
# VALUE + required checks over the KNOWN specs (owned or not — DEEPSEEK_API_KEY
|
|
187
|
+
# is not prefixed). Dual-read: the canonical name wins, the legacy alias is honored.
|
|
188
|
+
specs.each_value do |s|
|
|
189
|
+
legacy = legacy_alias(s.name)
|
|
190
|
+
set_name = if env.key?(s.name) then s.name
|
|
191
|
+
elsif legacy && env.key?(legacy) then legacy
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if set_name.nil?
|
|
195
|
+
findings << Finding.new(key: s.name, kind: :missing_required, severity: :error,
|
|
196
|
+
message: "#{s.name} is required (unset)") if s.required?
|
|
197
|
+
next
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
msg = s.error_for(env[set_name])
|
|
201
|
+
findings << Finding.new(key: set_name, kind: :invalid, severity: :error, message: "#{set_name} #{msg}") if msg
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# OWNED-PREFIX scan: a legacy alias of a known key is DEPRECATED (warn); any other
|
|
205
|
+
# owned key with no matching spec is UNKNOWN (a typo the runtime would ignore).
|
|
206
|
+
env.each_key do |raw|
|
|
207
|
+
key = raw.to_s
|
|
208
|
+
next unless owned?(key)
|
|
209
|
+
|
|
210
|
+
if legacy?(key) && specs.key?(canonical(key))
|
|
211
|
+
findings << Finding.new(key: key, kind: :deprecated, severity: :warn,
|
|
212
|
+
message: "#{key} is deprecated — rename to #{canonical(key)}")
|
|
213
|
+
elsif !specs.key?(canonical(key)) && !specs.key?(key)
|
|
214
|
+
findings << Finding.new(key: key, kind: :unknown, severity: :error,
|
|
215
|
+
message: "#{key} is not a known config key (typo? unknown key in an owned namespace)")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
findings
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# -> the specs the engine + a root know about (DEFAULT + extra), for `insika env`.
|
|
223
|
+
def known_specs(extra: []) = (DEFAULT + Array(extra)).sort_by(&:name)
|
|
224
|
+
|
|
225
|
+
# BOOT GATE. Validates the environment, WARNS every finding via `warn` (a callable
|
|
226
|
+
# taking a String), and RAISES ConfigError only when strict. `strict` defaults to
|
|
227
|
+
# the INSIKA_CONFIG_STRICT flag (HARNESS_CONFIG_STRICT still honored). -> [Finding]
|
|
228
|
+
# (also on the happy path). The root keeps booting on warnings (last-known-good).
|
|
229
|
+
def enforce!(env = ENV, extra: [], strict: nil, warn: method(:default_warn))
|
|
230
|
+
strict = truthy?(read("INSIKA_CONFIG_STRICT", env)) if strict.nil?
|
|
231
|
+
findings = validate(env, extra: extra)
|
|
232
|
+
findings.each { |f| warn.call("config: #{f.message}") }
|
|
233
|
+
|
|
234
|
+
errors = findings.select { |f| f.severity == :error }
|
|
235
|
+
raise Insika::ConfigError.new("strict config check refused boot", findings: errors) if strict && errors.any?
|
|
236
|
+
|
|
237
|
+
findings
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def default_warn(message) = Kernel.warn("[config] #{message}")
|
|
241
|
+
|
|
242
|
+
# -- internal ------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def index(specs) = specs.each_with_object({}) { |s, acc| acc[s.name] = s }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Single error taxonomy.
|
|
5
|
+
# General rule: an error becomes an event, a task has an explicit terminal
|
|
6
|
+
# state, a checkpoint is never corrupted.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
class ValidationError < Error; end # Malformed Command -> HTTP 422, no Task created
|
|
10
|
+
class NotFoundError < Error; end # nonexistent session/task/agent -> HTTP 404
|
|
11
|
+
|
|
12
|
+
# Policy Engine denied -> :policy_denied event, task :failed
|
|
13
|
+
class PolicyDenied < Error
|
|
14
|
+
attr_reader :policy, :reason
|
|
15
|
+
|
|
16
|
+
def initialize(message = nil, policy: nil, reason: nil)
|
|
17
|
+
@policy = policy
|
|
18
|
+
@reason = reason
|
|
19
|
+
super(message || "policy #{policy} denied: #{reason}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A required provider failed -> task :failed
|
|
24
|
+
class ContextError < Error
|
|
25
|
+
attr_reader :provider
|
|
26
|
+
|
|
27
|
+
def initialize(message = nil, provider: nil)
|
|
28
|
+
@provider = provider
|
|
29
|
+
super(message || "provider #{provider} failed")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class ProviderError < Error; end # RubyLLM exhausted retries -> task :failed
|
|
34
|
+
class StoreError < Error; end # persistence backend failed -> task :failed
|
|
35
|
+
class CancelledError < Error; end # cooperative cancellation -> task :cancelled
|
|
36
|
+
|
|
37
|
+
# Stage timeout overflow. Inside the Insika namespace this constant
|
|
38
|
+
# shadows the stdlib ::Timeout::Error — reference it without :: in here
|
|
39
|
+
# (the contract forbids stdlib Timeout.timeout anyway).
|
|
40
|
+
class TimeoutError < Error
|
|
41
|
+
attr_reader :stage
|
|
42
|
+
|
|
43
|
+
def initialize(message = nil, stage: nil)
|
|
44
|
+
@stage = stage
|
|
45
|
+
super(message || "timeout at stage #{stage}")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Capability resolution failed -> task :failed at the
|
|
50
|
+
# :capability stage. Root of the subtree; it does NOT get its own event —
|
|
51
|
+
# it propagates through the existing :error/:task_failed events, same
|
|
52
|
+
# discipline as the taxonomy. Never raised directly (only its subclasses).
|
|
53
|
+
class CapabilityError < Error; end
|
|
54
|
+
|
|
55
|
+
# 0 candidates left after availability + deny.
|
|
56
|
+
class CapabilityUnavailable < CapabilityError
|
|
57
|
+
attr_reader :capability
|
|
58
|
+
|
|
59
|
+
def initialize(message = nil, capability: nil)
|
|
60
|
+
@capability = capability
|
|
61
|
+
super(message || "capability #{capability} has no available provider")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# >=2 candidates tied at the top (same priority AND same plugin) -> a
|
|
66
|
+
# configuration error, NEVER a silent choice. `candidates` carries enough
|
|
67
|
+
# for the operator to break the tie in the manifest.
|
|
68
|
+
class CapabilityAmbiguous < CapabilityError
|
|
69
|
+
attr_reader :capability, :candidates
|
|
70
|
+
|
|
71
|
+
def initialize(message = nil, capability: nil, candidates: [])
|
|
72
|
+
@capability = capability
|
|
73
|
+
@candidates = candidates
|
|
74
|
+
super(message || "capability #{capability} ambiguous between #{candidates.inspect}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Subagent graph integrity (RFC-0010 §4.4). Raised at DEFINITION-time
|
|
79
|
+
# (CreateAgent/UpdateAgent/boot) by SubagentGraph.validate! — a subagents
|
|
80
|
+
# allowlist that forms a cycle or exceeds the depth cap is a configuration
|
|
81
|
+
# error, never a runtime surprise. A ValidationError so the authoring Command
|
|
82
|
+
# fails cleanly (HTTP 422, no profile persisted).
|
|
83
|
+
class SubagentError < ValidationError; end
|
|
84
|
+
|
|
85
|
+
class SubagentCycleError < SubagentError
|
|
86
|
+
attr_reader :cycle
|
|
87
|
+
|
|
88
|
+
def initialize(message = nil, cycle: [])
|
|
89
|
+
@cycle = cycle
|
|
90
|
+
super(message || "subagent cycle detected: #{cycle.join(' -> ')}")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class SubagentDepthExceeded < SubagentError
|
|
95
|
+
attr_reader :depth, :cap
|
|
96
|
+
|
|
97
|
+
def initialize(message = nil, depth: nil, cap: nil)
|
|
98
|
+
@depth = depth
|
|
99
|
+
@cap = cap
|
|
100
|
+
super(message || "subagent depth #{depth} exceeds cap #{cap}")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Workflow I/O contract violation (item 22 / §4.4). A workflow may declare an
|
|
105
|
+
# `input_schema` / `output_schema`; a value that does not conform is rejected.
|
|
106
|
+
# INPUT is validated SYNCHRONOUSLY (TriggerWorkflow) so it is a ValidationError
|
|
107
|
+
# -> HTTP 422, no run created. OUTPUT is validated inside the fiber after the
|
|
108
|
+
# workflow returns -> task :failed at the :workflow_schema stage. `errors` is the
|
|
109
|
+
# per-field detail (dry-schema-compatible `#errors.to_h`); `phase` is :input|:output.
|
|
110
|
+
class WorkflowSchemaError < ValidationError
|
|
111
|
+
attr_reader :phase, :errors
|
|
112
|
+
|
|
113
|
+
def initialize(message = nil, phase: nil, errors: {})
|
|
114
|
+
@phase = phase
|
|
115
|
+
@errors = errors || {}
|
|
116
|
+
detail = @errors.map { |field, msgs| "#{field}: #{Array(msgs).join(', ')}" }.join("; ")
|
|
117
|
+
base = message || "workflow #{phase} failed schema validation"
|
|
118
|
+
super(detail.empty? ? base : "#{base} (#{detail})")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A channel could not hand a reply to its recipient (RFC-0011 §6.5). NOT a turn
|
|
123
|
+
# failure: the turn already completed and its answer is durable in the session —
|
|
124
|
+
# what failed is the delivery, which lives in the OutboxStore with its own status
|
|
125
|
+
# and its own bounded retry. Raised by a channel's `deliver` so the dispatcher can
|
|
126
|
+
# tell "the recipient refused" from "the engine has a bug".
|
|
127
|
+
class DeliveryError < Error; end
|
|
128
|
+
|
|
129
|
+
# Strict configuration violation (item 23 / §8.1 — OpenClaw's config discipline:
|
|
130
|
+
# "recusa boot com chave desconhecida, no silent config compat"). Raised by
|
|
131
|
+
# EnvSchema.enforce! at boot ONLY when strictness is on (INSIKA_CONFIG_STRICT) —
|
|
132
|
+
# by default a bad key WARNS and the engine still boots (last-known-good: a rotated
|
|
133
|
+
# env or a typo never takes the whole service down). `findings` carries the
|
|
134
|
+
# per-key detail (EnvSchema::Finding) so the operator can fix the config.
|
|
135
|
+
class ConfigError < Error
|
|
136
|
+
attr_reader :findings
|
|
137
|
+
|
|
138
|
+
def initialize(message = nil, findings: [])
|
|
139
|
+
@findings = findings || []
|
|
140
|
+
detail = @findings.map { |f| f.respond_to?(:message) ? f.message : f.to_s }.join("; ")
|
|
141
|
+
base = message || "strict config check failed"
|
|
142
|
+
super(detail.empty? ? base : "#{base}: #{detail}")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# D4 (RFC-0009): the PII/secret patterns live in the RUNTIME (single source of
|
|
4
|
+
# truth) — the eval consumes them rather than keeping a divergent copy. Since the
|
|
5
|
+
# module moved under `lib/`, `Safety::Detectors` is loaded by `insika.rb` before this
|
|
6
|
+
# file; the explicit climb out of `evals/` that used to be here is gone.
|
|
7
|
+
|
|
8
|
+
module Insika
|
|
9
|
+
module Evals
|
|
10
|
+
# What the runner extracts from ONE replayed conversation, entirely from the
|
|
11
|
+
# public SSE stream of POST /v1/responses (no store reads — the eval stays a
|
|
12
|
+
# client). The assertion engine is pure over this value, so it's unit-testable
|
|
13
|
+
# offline without a server.
|
|
14
|
+
#
|
|
15
|
+
# output_text: the final assistant text (for content checks)
|
|
16
|
+
# tool_calls: [{ "name" =>, "status" => }] captured from the stream's tool events
|
|
17
|
+
# error: transport/turn error string, or nil on a clean turn
|
|
18
|
+
TurnResult = Struct.new(:output_text, :tool_calls, :error, keyword_init: true) do
|
|
19
|
+
def tool_names = Array(tool_calls).map { |t| (t["name"] || t[:name]).to_s }
|
|
20
|
+
|
|
21
|
+
# A tool call whose status is anything but a success ("ok"/2xx/"success").
|
|
22
|
+
def errored_tools
|
|
23
|
+
Array(tool_calls).reject { |t| Assertions.ok_status?(t["status"] || t[:status]) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A single check within a case (e.g. "tool:shipping_quote", "must_not:pii_leak").
|
|
28
|
+
Check = Struct.new(:name, :pass, :detail, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
# The verdict for one golden case. `judge` (a Judge::Verdict) is attached AFTER
|
|
31
|
+
# the deterministic pass when the case has a rubric and a judge is configured;
|
|
32
|
+
# until then a rubric'd case is `judge_pending?` — it reads as "not fully
|
|
33
|
+
# evaluated", never a silent pass. A case passes only if the deterministic checks
|
|
34
|
+
# pass AND (there's no judge verdict OR it passed).
|
|
35
|
+
#
|
|
36
|
+
# `skipped` (a reason, nil = it ran) is the THIRD outcome (RFC-0014 §3.2): the
|
|
37
|
+
# deployment lacks something the case declared it needs, so there was nothing to
|
|
38
|
+
# assert. It is never a pass and never a failure — a suite of 40 cases where 12
|
|
39
|
+
# are skipped says something true, where 40 cases with 12 failures on capability
|
|
40
|
+
# grounds says nothing and gets ignored.
|
|
41
|
+
# `pairwise` (a Pairwise::Verdict, RFC-0014 §3.4) is attached when the case
|
|
42
|
+
# carries a `reference:` and a panel is configured. It DELIBERATELY does not enter
|
|
43
|
+
# `pass?`: "worse than the incumbent" is a judgement about a replacement decision,
|
|
44
|
+
# not a regression in the suite, and letting it fail a case would put an opinion
|
|
45
|
+
# about two conversations in the pre-merge gate.
|
|
46
|
+
CaseResult = Struct.new(:id, :agent, :checks, :error, :rubric, :judge, :skipped, :pairwise,
|
|
47
|
+
keyword_init: true) do
|
|
48
|
+
def skipped? = !skipped.nil?
|
|
49
|
+
def pass? = !skipped? && error.nil? && checks.all?(&:pass) && (judge.nil? || judge.pass)
|
|
50
|
+
def failures = checks.reject(&:pass)
|
|
51
|
+
# Has a rubric to score but no verdict yet (judge disabled / not run). A skipped
|
|
52
|
+
# case is not pending anything — nobody is going to judge a turn that never ran.
|
|
53
|
+
def judge_pending? = !skipped? && !rubric.to_s.strip.empty? && judge.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Deterministic (Fase A) evaluation — cheap, zero-token, zero-flakiness. It's the
|
|
57
|
+
# layer that catches the gross regressions (a tool stopped being called, a secret
|
|
58
|
+
# leaked, the turn errored). Subjective scoring is the LLM-judge in Fase B.
|
|
59
|
+
module Assertions
|
|
60
|
+
# Named negative detectors for `must_not` now live in the runtime (D4). Kept as
|
|
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
|
+
PII_DETECTORS = Insika::Safety::Detectors::PII
|
|
64
|
+
|
|
65
|
+
# HOW MUCH THE AGENT SHOULD ASK BEFORE ACTING (RFC-0014 §3.3). Declared per
|
|
66
|
+
# case because it is a per-STORE decision, not a universal rule: sometimes the
|
|
67
|
+
# agent should establish the objective before searching ("energia, treino ou
|
|
68
|
+
# sono?" — Ocean Drop does this well), and sometimes asking again is the
|
|
69
|
+
# failure and it should just search. A global assertion would be wrong half
|
|
70
|
+
# the time; the judge is TOLD the policy (Judge#build_prompt) and this layer
|
|
71
|
+
# checks the half that needs no reader.
|
|
72
|
+
#
|
|
73
|
+
# Each rule is stated as the CUSTOMER-VISIBLE fact it checks. RFC-0014 phrased
|
|
74
|
+
# this as "questions before the first tool call", written before P19: text a
|
|
75
|
+
# model emits before calling a tool never reaches the customer now (it rides
|
|
76
|
+
# `:intermediate`), and the eval is a client of `/v1/responses`, so what it can
|
|
77
|
+
# observe per turn is the published answer plus the tools that turn called.
|
|
78
|
+
# That is also the honest scope — a question nobody received is not a question.
|
|
79
|
+
POLICIES = {
|
|
80
|
+
# "UMA PERGUNTA POR VEZ" — the rule Insika broke twice under a 28 KB prompt.
|
|
81
|
+
"ask_once" => "at most one question per reply",
|
|
82
|
+
# Establish the objective before acting on a vague opener.
|
|
83
|
+
"investigate_first" => "asks before calling a tool, on the first turn",
|
|
84
|
+
# Act on the first plausible reading; refine after.
|
|
85
|
+
"act_fast" => "calls a tool on the first turn instead of asking"
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
module_function
|
|
89
|
+
|
|
90
|
+
# WHAT THIS DEPLOYMENT LACKS for the case to be worth running (RFC-0014 §3.2).
|
|
91
|
+
# -> [reason]; empty = run it.
|
|
92
|
+
#
|
|
93
|
+
# `available` is the deployment's answer for this agent:
|
|
94
|
+
# { "tools" => [names] | nil, "capabilities" => [names] }
|
|
95
|
+
# `tools` nil means an OPEN allowlist — the agent may call every registered tool,
|
|
96
|
+
# so no tool requirement can be judged missing and the case runs. That is the
|
|
97
|
+
# deliberate reading: "I could not rule it out" must not become a skip, or a
|
|
98
|
+
# permissive agent would quietly stop being tested.
|
|
99
|
+
def unmet_requirements(golden, available)
|
|
100
|
+
tools = available["tools"]
|
|
101
|
+
declared = Array(available["capabilities"]).map(&:to_s)
|
|
102
|
+
|
|
103
|
+
missing_tools = tools.nil? ? [] : golden.required_tools - Array(tools).map(&:to_s)
|
|
104
|
+
missing_caps = golden.required_capabilities - declared
|
|
105
|
+
|
|
106
|
+
reasons = []
|
|
107
|
+
reasons << "tool not available: #{missing_tools.join(', ')}" unless missing_tools.empty?
|
|
108
|
+
reasons << "capability not declared: #{missing_caps.join(', ')}" unless missing_caps.empty?
|
|
109
|
+
reasons
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The case did not run and MUST NOT read as either a pass or a failure.
|
|
113
|
+
def skip(golden, reason)
|
|
114
|
+
CaseResult.new(id: golden.id, agent: golden.agent, checks: [], error: nil,
|
|
115
|
+
rubric: nil, judge: nil, skipped: reason)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A tool status counts as success when it's blank/"ok"/"success" or a 2xx code.
|
|
119
|
+
def ok_status?(status)
|
|
120
|
+
return true if status.nil?
|
|
121
|
+
|
|
122
|
+
s = status.to_s.strip.downcase
|
|
123
|
+
return true if s.empty? || %w[ok success succeeded done].include?(s)
|
|
124
|
+
|
|
125
|
+
code = Integer(s, exception: false)
|
|
126
|
+
code ? code.between?(200, 299) : false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Golden + TurnResult -> CaseResult. A turn that failed to run yields a single
|
|
130
|
+
# failing check (there's nothing to assert on a turn that never produced output).
|
|
131
|
+
#
|
|
132
|
+
# `turns` is every turn of the conversation, in order; `result` is the last one
|
|
133
|
+
# (what the tool/content assertions have always run on). The policy checks need
|
|
134
|
+
# all of them — "one question per reply" is a rule about every reply, and the
|
|
135
|
+
# violation that motivated this was on the FIRST turn. Defaults to the single
|
|
136
|
+
# result so existing callers keep working.
|
|
137
|
+
def evaluate(golden, result, turns: nil)
|
|
138
|
+
if result.error
|
|
139
|
+
return CaseResult.new(id: golden.id, agent: golden.agent, error: result.error, rubric: nil, judge: nil,
|
|
140
|
+
checks: [Check.new(name: "turn", pass: false, detail: "turn error: #{result.error}")])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# NOT `Array(turns)`: TurnResult is a Struct, so Array() would explode a single
|
|
144
|
+
# one into its members and hand the policy checks three strings.
|
|
145
|
+
conversation = turns.nil? || turns.empty? ? [result] : turns
|
|
146
|
+
checks = tool_checks(golden, result) + must_not_checks(golden, result) +
|
|
147
|
+
policy_checks(golden, conversation)
|
|
148
|
+
CaseResult.new(id: golden.id, agent: golden.agent, error: nil, checks: checks,
|
|
149
|
+
rubric: golden.rubric, judge: nil)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Each REQUIRED expected tool must appear in the turn's tool calls. Optional
|
|
153
|
+
# ("name?") tools are informational — present or not, they never fail.
|
|
154
|
+
def tool_checks(golden, result)
|
|
155
|
+
names = result.tool_names
|
|
156
|
+
golden.tools_called.filter_map do |t|
|
|
157
|
+
next if t[:optional]
|
|
158
|
+
|
|
159
|
+
present = names.include?(t[:name])
|
|
160
|
+
Check.new(name: "tool:#{t[:name]}", pass: present,
|
|
161
|
+
detail: present ? "called" : "expected but not called (saw: #{names.join(', ')})")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# `must_not` detectors. "tool_error" is special (inspects statuses); the rest
|
|
166
|
+
# are content detectors over the output text.
|
|
167
|
+
def must_not_checks(golden, result)
|
|
168
|
+
golden.must_not.map do |name|
|
|
169
|
+
if name == "tool_error"
|
|
170
|
+
bad = result.errored_tools
|
|
171
|
+
Check.new(name: "must_not:tool_error", pass: bad.empty?,
|
|
172
|
+
detail: bad.empty? ? "no tool errors" : "errored: #{bad.map { |t| t['name'] || t[:name] }.join(', ')}")
|
|
173
|
+
else
|
|
174
|
+
hit = detect(name, result.output_text.to_s)
|
|
175
|
+
Check.new(name: "must_not:#{name}", pass: hit.nil?,
|
|
176
|
+
detail: hit ? "matched #{hit.inspect}" : "clean")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The declared `policy`, checked deterministically over the conversation. No
|
|
182
|
+
# policy -> no check (and nothing to explain in the report).
|
|
183
|
+
def policy_checks(golden, turns)
|
|
184
|
+
name = golden.policy
|
|
185
|
+
return [] if name.nil?
|
|
186
|
+
|
|
187
|
+
# Exhaustive on purpose: a policy added to POLICIES without a rule here would
|
|
188
|
+
# otherwise fall into whichever branch was last and check the wrong thing.
|
|
189
|
+
pass, detail = case name
|
|
190
|
+
when "ask_once" then ask_once(turns)
|
|
191
|
+
when "investigate_first" then investigate_first(turns.first)
|
|
192
|
+
when "act_fast" then act_fast(turns.first)
|
|
193
|
+
else raise ArgumentError, "policy #{name.inspect} has no rule"
|
|
194
|
+
end
|
|
195
|
+
[Check.new(name: "policy:#{name}", pass: pass, detail: detail)]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Every reply asks at most one question. Reported with the offending turn and
|
|
199
|
+
# the reply itself — "2 questions" alone sends the reader digging.
|
|
200
|
+
def ask_once(turns)
|
|
201
|
+
offender = turns.each_with_index.find { |t, _| count_questions(t.output_text) > 1 }
|
|
202
|
+
return [true, "at most one question per reply"] unless offender
|
|
203
|
+
|
|
204
|
+
turn, i = offender
|
|
205
|
+
[false, "turn #{i + 1} asked #{count_questions(turn.output_text)} questions: " \
|
|
206
|
+
"#{turn.output_text.to_s.strip[0, 160].inspect}"]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def investigate_first(turn)
|
|
210
|
+
return [false, "no turn to check"] if turn.nil?
|
|
211
|
+
|
|
212
|
+
tools = turn.tool_names
|
|
213
|
+
return [false, "called #{tools.join(', ')} before asking anything"] unless tools.empty?
|
|
214
|
+
return [false, "answered without asking: #{turn.output_text.to_s.strip[0, 160].inspect}"] if
|
|
215
|
+
count_questions(turn.output_text).zero?
|
|
216
|
+
|
|
217
|
+
[true, "asked before acting"]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def act_fast(turn)
|
|
221
|
+
return [false, "no turn to check"] if turn.nil?
|
|
222
|
+
|
|
223
|
+
tools = turn.tool_names
|
|
224
|
+
return [true, "acted: called #{tools.join(', ')}"] unless tools.empty?
|
|
225
|
+
|
|
226
|
+
[false, "asked instead of acting: #{turn.output_text.to_s.strip[0, 160].inspect}"]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Questions in ONE reply. Deliberately crude and deliberately documented: a run
|
|
230
|
+
# of "?" counts once ("já pensou??" is one question), and URLs are dropped first
|
|
231
|
+
# so a tracking link's query string is not read as the agent asking something.
|
|
232
|
+
# It is a policy signal, not grammar — and it already caught a real violation
|
|
233
|
+
# ("é pra você ou tá pensando em presentear alguém? E qual seu tamanho?").
|
|
234
|
+
def count_questions(text)
|
|
235
|
+
text.to_s.gsub(%r{https?://\S+}, " ").scan(/\?+/).size
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Runs a named detector over the text. "pii_leak" = union of all PII detectors;
|
|
239
|
+
# otherwise a single named pattern. Delegates to the runtime's single source
|
|
240
|
+
# (D4) — which itself fails loud on an unknown name (a typo'd assertion must not
|
|
241
|
+
# silently pass).
|
|
242
|
+
def detect(name, text)
|
|
243
|
+
Insika::Safety::Detectors.detect(name, text)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|