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,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
require "resolv"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# EGRESS guard for data-tools (SSRF). A data-tool makes a server-side HTTP
|
|
9
|
+
# request with a URL coming from UI-editable config — without a guard, it's an
|
|
10
|
+
# SSRF vector (hitting cloud metadata, internal services, localhost). Rules
|
|
11
|
+
# (spec):
|
|
12
|
+
# - https only by default (http requires explicit opt-in);
|
|
13
|
+
# - host required;
|
|
14
|
+
# - optional host allowlist (when present, only it passes);
|
|
15
|
+
# - resolves the host and BLOCKS if ANY address falls into a private/
|
|
16
|
+
# loopback/link-local/metadata network (defense against DNS rebinding);
|
|
17
|
+
# - `allow_private:` (opt-in) ALLOWS the private target — to reach a trusted
|
|
18
|
+
# INTERNAL API (the consumer's /api/internal/* comes in via an
|
|
19
|
+
# allowlist). Dangerous without `host_allowlist`: PIN it to a known host.
|
|
20
|
+
# Default false = strict guard.
|
|
21
|
+
#
|
|
22
|
+
# `violation(url, ...)` returns nil (ok) or a String with the reason — the
|
|
23
|
+
# DataDefinedTool turns the reason into `{ error: }` to the model (never raises).
|
|
24
|
+
module EgressGuard
|
|
25
|
+
BLOCKED = [
|
|
26
|
+
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
|
|
27
|
+
"169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16",
|
|
28
|
+
"198.18.0.0/15", "::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96"
|
|
29
|
+
].map { |c| IPAddr.new(c) }.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# -> nil (allowed) | String (block reason).
|
|
34
|
+
def violation(url, allow_http: false, host_allowlist: nil, allow_private: false)
|
|
35
|
+
uri = begin
|
|
36
|
+
URI.parse(url.to_s)
|
|
37
|
+
rescue URI::InvalidURIError
|
|
38
|
+
return "invalid URL"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
return "unsupported scheme" unless %w[http https].include?(uri.scheme)
|
|
42
|
+
return "http not allowed (use https)" if uri.scheme == "http" && !allow_http
|
|
43
|
+
|
|
44
|
+
host = uri.host
|
|
45
|
+
return "missing host" if host.nil? || host.empty?
|
|
46
|
+
return "host not in allowlist" if host_allowlist && !host_allowlist.include?(host)
|
|
47
|
+
|
|
48
|
+
addrs = resolve(host)
|
|
49
|
+
return "host did not resolve" if addrs.empty?
|
|
50
|
+
# allow_private skips the private-network block (trusted internal API,
|
|
51
|
+
# Without it, a private/loopback/metadata target is always blocked.
|
|
52
|
+
return "private-network destination blocked" if !allow_private && addrs.any? { |ip| blocked?(ip) }
|
|
53
|
+
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Literal host (IP) -> itself; hostname -> resolve via DNS. -> [IPAddr].
|
|
58
|
+
def resolve(host)
|
|
59
|
+
literal = ip_or_nil(host.delete_prefix("[").delete_suffix("]"))
|
|
60
|
+
return [literal] if literal
|
|
61
|
+
|
|
62
|
+
Resolv.getaddresses(host).filter_map { |a| ip_or_nil(a) }
|
|
63
|
+
rescue Resolv::ResolvError, SocketError
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def blocked?(ip) = BLOCKED.any? { |net| net.include?(ip) }
|
|
68
|
+
|
|
69
|
+
def ip_or_nil(str)
|
|
70
|
+
IPAddr.new(str)
|
|
71
|
+
rescue IPAddr::InvalidAddressError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# STRICT config, environment layer. 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_*/CONSUMER_*) 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_*, CONSUMER_*, …) 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)."),
|
|
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."),
|
|
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. 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)."),
|
|
116
|
+
spec(name: "INSIKA_TICK_INTERVAL", type: :integer, description: "Seconds between tick passes (outbox drain + stale recovery sweep). Default 60; 0 disables."),
|
|
117
|
+
spec(name: "INSIKA_TICK_STALE_AFTER", type: :integer, description: "Seconds a :queued/:running task must sit untouched before the tick sweeps it (default 900). Must exceed the largest turn_timeout of the deployment."),
|
|
118
|
+
spec(name: "INSIKA_TENANCY", enum: %w[single_tenant multi_tenant], description: "single_tenant (default: one operator credential) or multi_tenant (per-tenant + operator tokens resolved from the store)."),
|
|
119
|
+
spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in)."),
|
|
120
|
+
spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted."),
|
|
121
|
+
spec(name: "INSIKA_RELAY_DELIVER_URL", type: :url, description: "Consumer callback the relay POSTs each reply to."),
|
|
122
|
+
spec(name: "INSIKA_RELAY_DELIVER_TOKEN", secret: true, description: "Bearer the relay sends TO the consumer's callback (optional)."),
|
|
123
|
+
spec(name: "INSIKA_WIDGET_ORIGINS", type: :csv, description: "Exact-match origins allowed to embed the web widget. Unset -> the widget channel is not mounted."),
|
|
124
|
+
spec(name: "INSIKA_WIDGET_AGENTS", type: :csv, description: "Agent ids a widget visitor may address. Unset -> the widget channel is not mounted."),
|
|
125
|
+
spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
|
|
126
|
+
spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
|
|
127
|
+
spec(name: "OPENCLAW_PLUGIN_DIR", type: :path, description: "Directory of plugins to load."),
|
|
128
|
+
spec(name: "ADMIN_TOKEN", secret: true, description: "Studio login token; unset -> /studio fail-closed."),
|
|
129
|
+
spec(name: "OTEL_SERVICE_NAME", description: "Service name for OTEL spans (default: insika).")
|
|
130
|
+
].freeze
|
|
131
|
+
|
|
132
|
+
# -- dual-read (rename compat) -------------------------------------
|
|
133
|
+
|
|
134
|
+
# HARNESS_X -> INSIKA_X for the deprecated alias of a canonical key; nil if `name`
|
|
135
|
+
# is not an INSIKA_ key.
|
|
136
|
+
def legacy_alias(name)
|
|
137
|
+
s = name.to_s
|
|
138
|
+
s.start_with?(PREFIX) ? LEGACY_PREFIX + s[PREFIX.length..] : nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The current (canonical) name for any owned key: HARNESS_X -> INSIKA_X; an INSIKA_
|
|
142
|
+
# key or any non-owned key is returned unchanged.
|
|
143
|
+
def canonical(name)
|
|
144
|
+
s = name.to_s
|
|
145
|
+
s.start_with?(LEGACY_PREFIX) ? PREFIX + s[LEGACY_PREFIX.length..] : s
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def legacy?(name) = name.to_s.start_with?(LEGACY_PREFIX)
|
|
149
|
+
def owned?(name) = OWNED_PREFIXES.any? { |p| name.to_s.start_with?(p) }
|
|
150
|
+
|
|
151
|
+
# Reads a canonical INSIKA_* key, falling back to the deprecated HARNESS_* alias
|
|
152
|
+
# (new name wins). For the self-contained readers that get an env hash directly and
|
|
153
|
+
# so never see `reconcile_legacy!`'s process-wide backfill. -> value | nil.
|
|
154
|
+
def read(canonical_name, env = ENV)
|
|
155
|
+
value = env[canonical_name]
|
|
156
|
+
return value if present?(value)
|
|
157
|
+
|
|
158
|
+
(legacy = legacy_alias(canonical_name)) ? env[legacy] : value
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# BOOT backfill: copies every still-set HARNESS_* var to its INSIKA_* name (new name
|
|
162
|
+
# wins if both are set) so the process ENV speaks the new names before any read.
|
|
163
|
+
# Generic (not schema-bound) so plugin/deployment HARNESS_* keys migrate too. WARNS
|
|
164
|
+
# once with the migrated list. -> [migrated legacy names]. Idempotent.
|
|
165
|
+
def reconcile_legacy!(env = ENV, warn: method(:default_warn))
|
|
166
|
+
migrated = env.keys.map(&:to_s).select { |k| k.start_with?(LEGACY_PREFIX) }.sort.each_with_object([]) do |legacy, acc|
|
|
167
|
+
canonical = PREFIX + legacy[LEGACY_PREFIX.length..]
|
|
168
|
+
next if present?(env[canonical]) # the new name already wins
|
|
169
|
+
next if env[legacy].nil?
|
|
170
|
+
|
|
171
|
+
env[canonical] = env[legacy]
|
|
172
|
+
acc << legacy
|
|
173
|
+
end
|
|
174
|
+
unless migrated.empty?
|
|
175
|
+
warn.call("deprecation: #{migrated.join(', ')} — HARNESS_* env vars are renamed to INSIKA_*; " \
|
|
176
|
+
"honored via the new names this run, please update your environment (legacy names removed in a future release)")
|
|
177
|
+
end
|
|
178
|
+
migrated
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# -- validation ----------------------------------------------------
|
|
182
|
+
|
|
183
|
+
# -> [Finding]. `extra` = deployment/app specs to fold into the known set (and to
|
|
184
|
+
# widen unknown-key detection over their names). Never raises.
|
|
185
|
+
def validate(env = ENV, extra: [])
|
|
186
|
+
specs = index(DEFAULT + Array(extra))
|
|
187
|
+
findings = []
|
|
188
|
+
|
|
189
|
+
# VALUE + required checks over the KNOWN specs (owned or not — DEEPSEEK_API_KEY
|
|
190
|
+
# is not prefixed). Dual-read: the canonical name wins, the legacy alias is honored.
|
|
191
|
+
specs.each_value do |s|
|
|
192
|
+
legacy = legacy_alias(s.name)
|
|
193
|
+
set_name = if env.key?(s.name) then s.name
|
|
194
|
+
elsif legacy && env.key?(legacy) then legacy
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if set_name.nil?
|
|
198
|
+
findings << Finding.new(key: s.name, kind: :missing_required, severity: :error,
|
|
199
|
+
message: "#{s.name} is required (unset)") if s.required?
|
|
200
|
+
next
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
msg = s.error_for(env[set_name])
|
|
204
|
+
findings << Finding.new(key: set_name, kind: :invalid, severity: :error, message: "#{set_name} #{msg}") if msg
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# OWNED-PREFIX scan: a legacy alias of a known key is DEPRECATED (warn); any other
|
|
208
|
+
# owned key with no matching spec is UNKNOWN (a typo the runtime would ignore).
|
|
209
|
+
env.each_key do |raw|
|
|
210
|
+
key = raw.to_s
|
|
211
|
+
next unless owned?(key)
|
|
212
|
+
|
|
213
|
+
if legacy?(key) && specs.key?(canonical(key))
|
|
214
|
+
findings << Finding.new(key: key, kind: :deprecated, severity: :warn,
|
|
215
|
+
message: "#{key} is deprecated — rename to #{canonical(key)}")
|
|
216
|
+
elsif !specs.key?(canonical(key)) && !specs.key?(key)
|
|
217
|
+
findings << Finding.new(key: key, kind: :unknown, severity: :error,
|
|
218
|
+
message: "#{key} is not a known config key (typo? unknown key in an owned namespace)")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
findings
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# -> the specs the engine + a root know about (DEFAULT + extra), for `insika env`.
|
|
226
|
+
def known_specs(extra: []) = (DEFAULT + Array(extra)).sort_by(&:name)
|
|
227
|
+
|
|
228
|
+
# BOOT GATE. Validates the environment, WARNS every finding via `warn` (a callable
|
|
229
|
+
# taking a String), and RAISES ConfigError only when strict. `strict` defaults to
|
|
230
|
+
# the INSIKA_CONFIG_STRICT flag (HARNESS_CONFIG_STRICT still honored). -> [Finding]
|
|
231
|
+
# (also on the happy path). The root keeps booting on warnings (last-known-good).
|
|
232
|
+
def enforce!(env = ENV, extra: [], strict: nil, warn: method(:default_warn))
|
|
233
|
+
strict = truthy?(read("INSIKA_CONFIG_STRICT", env)) if strict.nil?
|
|
234
|
+
findings = validate(env, extra: extra)
|
|
235
|
+
findings.each { |f| warn.call("config: #{f.message}") }
|
|
236
|
+
|
|
237
|
+
errors = findings.select { |f| f.severity == :error }
|
|
238
|
+
raise Insika::ConfigError.new("strict config check refused boot", findings: errors) if strict && errors.any?
|
|
239
|
+
|
|
240
|
+
findings
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def default_warn(message) = Kernel.warn("[config] #{message}")
|
|
244
|
+
|
|
245
|
+
# -- internal ------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def index(specs) = specs.each_with_object({}) { |s, acc| acc[s.name] = s }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
# A provider/transport failure, wrapped by ProviderErrorClassifier with an
|
|
34
|
+
# ACTION classification (B9). The fields ride in the task's error record and
|
|
35
|
+
# the :task_failed event so the client envelope can tell fatal from
|
|
36
|
+
# retryable and quote the provider's own retry_after (A8). A bare
|
|
37
|
+
# `ProviderError.new("boom")` has an empty classification and adds nothing
|
|
38
|
+
# to the contract.
|
|
39
|
+
class ProviderError < Error
|
|
40
|
+
attr_reader :kind, :retryable, :retry_after
|
|
41
|
+
|
|
42
|
+
def initialize(message = nil, kind: nil, retryable: nil, retry_after: nil)
|
|
43
|
+
@kind = kind
|
|
44
|
+
@retryable = retryable
|
|
45
|
+
@retry_after = retry_after
|
|
46
|
+
super(message)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# the additive envelope fields, compacted — nil retry_after stays absent.
|
|
50
|
+
def classification
|
|
51
|
+
{ kind: kind, retryable: retryable, retry_after: retry_after }.compact
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The hard budget refused the turn (WS2): the (tenant, agent) spend in the
|
|
56
|
+
# window already met its cap BEFORE the turn ran. A typed, retryable failure —
|
|
57
|
+
# the envelope reads `budget_exceeded` + `retry_after` (seconds until the
|
|
58
|
+
# window rolls) — never a silent drop. `window` is :daily | :monthly.
|
|
59
|
+
class BudgetExceeded < Error
|
|
60
|
+
attr_reader :window, :retry_after
|
|
61
|
+
|
|
62
|
+
def initialize(message = nil, window: nil, retry_after: nil)
|
|
63
|
+
@window = window
|
|
64
|
+
@retry_after = retry_after
|
|
65
|
+
super(message || "budget exceeded (#{window})")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def classification
|
|
69
|
+
{ kind: :budget_exceeded, retryable: true, retry_after: retry_after }.compact
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The circuit breaker refused the turn WITHOUT touching the provider (WS3):
|
|
74
|
+
# the (tenant, provider/model) saw `after` failures within `within` seconds.
|
|
75
|
+
# Typed + retryable — the envelope reads `circuit_open` + `retry_after`
|
|
76
|
+
# (seconds until the cooldown lets a half-open trial through).
|
|
77
|
+
class CircuitOpenError < Error
|
|
78
|
+
attr_reader :ref, :retry_after
|
|
79
|
+
|
|
80
|
+
def initialize(message = nil, ref: nil, retry_after: nil)
|
|
81
|
+
@ref = ref
|
|
82
|
+
@retry_after = retry_after
|
|
83
|
+
super(message || "circuit open for #{ref}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def classification
|
|
87
|
+
{ kind: :circuit_open, retryable: true, retry_after: retry_after }.compact
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
class StoreError < Error; end # persistence backend failed -> task :failed
|
|
91
|
+
class CancelledError < Error; end # cooperative cancellation -> task :cancelled
|
|
92
|
+
|
|
93
|
+
# Stage timeout overflow. Inside the Insika namespace this constant
|
|
94
|
+
# shadows the stdlib ::Timeout::Error — reference it without :: in here
|
|
95
|
+
# (the contract forbids stdlib Timeout.timeout anyway).
|
|
96
|
+
class TimeoutError < Error
|
|
97
|
+
attr_reader :stage
|
|
98
|
+
|
|
99
|
+
def initialize(message = nil, stage: nil)
|
|
100
|
+
@stage = stage
|
|
101
|
+
super(message || "timeout at stage #{stage}")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Capability resolution failed -> task :failed at the
|
|
106
|
+
# :capability stage. Root of the subtree; it does NOT get its own event —
|
|
107
|
+
# it propagates through the existing :error/:task_failed events, same
|
|
108
|
+
# discipline as the taxonomy. Never raised directly (only its subclasses).
|
|
109
|
+
class CapabilityError < Error; end
|
|
110
|
+
|
|
111
|
+
# 0 candidates left after availability + deny.
|
|
112
|
+
class CapabilityUnavailable < CapabilityError
|
|
113
|
+
attr_reader :capability
|
|
114
|
+
|
|
115
|
+
def initialize(message = nil, capability: nil)
|
|
116
|
+
@capability = capability
|
|
117
|
+
super(message || "capability #{capability} has no available provider")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# >=2 candidates tied at the top (same priority AND same plugin) -> a
|
|
122
|
+
# configuration error, NEVER a silent choice. `candidates` carries enough
|
|
123
|
+
# for the operator to break the tie in the manifest.
|
|
124
|
+
class CapabilityAmbiguous < CapabilityError
|
|
125
|
+
attr_reader :capability, :candidates
|
|
126
|
+
|
|
127
|
+
def initialize(message = nil, capability: nil, candidates: [])
|
|
128
|
+
@capability = capability
|
|
129
|
+
@candidates = candidates
|
|
130
|
+
super(message || "capability #{capability} ambiguous between #{candidates.inspect}")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Subagent graph integrity. Raised at DEFINITION-time
|
|
135
|
+
# (CreateAgent/UpdateAgent/boot) by SubagentGraph.validate! — a subagents
|
|
136
|
+
# allowlist that forms a cycle or exceeds the depth cap is a configuration
|
|
137
|
+
# error, never a runtime surprise. A ValidationError so the authoring Command
|
|
138
|
+
# fails cleanly (HTTP 422, no profile persisted).
|
|
139
|
+
class SubagentError < ValidationError; end
|
|
140
|
+
|
|
141
|
+
class SubagentCycleError < SubagentError
|
|
142
|
+
attr_reader :cycle
|
|
143
|
+
|
|
144
|
+
def initialize(message = nil, cycle: [])
|
|
145
|
+
@cycle = cycle
|
|
146
|
+
super(message || "subagent cycle detected: #{cycle.join(' -> ')}")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
class SubagentDepthExceeded < SubagentError
|
|
151
|
+
attr_reader :depth, :cap
|
|
152
|
+
|
|
153
|
+
def initialize(message = nil, depth: nil, cap: nil)
|
|
154
|
+
@depth = depth
|
|
155
|
+
@cap = cap
|
|
156
|
+
super(message || "subagent depth #{depth} exceeds cap #{cap}")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Workflow I/O contract violation. A workflow may declare an
|
|
161
|
+
# `input_schema` / `output_schema`; a value that does not conform is rejected.
|
|
162
|
+
# INPUT is validated SYNCHRONOUSLY (TriggerWorkflow) so it is a ValidationError
|
|
163
|
+
# -> HTTP 422, no run created. OUTPUT is validated inside the fiber after the
|
|
164
|
+
# workflow returns -> task :failed at the :workflow_schema stage. `errors` is the
|
|
165
|
+
# per-field detail (dry-schema-compatible `#errors.to_h`); `phase` is :input|:output.
|
|
166
|
+
class WorkflowSchemaError < ValidationError
|
|
167
|
+
attr_reader :phase, :errors
|
|
168
|
+
|
|
169
|
+
def initialize(message = nil, phase: nil, errors: {})
|
|
170
|
+
@phase = phase
|
|
171
|
+
@errors = errors || {}
|
|
172
|
+
detail = @errors.map { |field, msgs| "#{field}: #{Array(msgs).join(', ')}" }.join("; ")
|
|
173
|
+
base = message || "workflow #{phase} failed schema validation"
|
|
174
|
+
super(detail.empty? ? base : "#{base} (#{detail})")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A channel could not hand a reply to its recipient. NOT a turn
|
|
179
|
+
# failure: the turn already completed and its answer is durable in the session —
|
|
180
|
+
# what failed is the delivery, which lives in the OutboxStore with its own status
|
|
181
|
+
# and its own bounded retry. Raised by a channel's `deliver` so the dispatcher can
|
|
182
|
+
# tell "the recipient refused" from "the engine has a bug".
|
|
183
|
+
class DeliveryError < Error; end
|
|
184
|
+
|
|
185
|
+
# Strict configuration violation (— OpenClaw's config discipline:
|
|
186
|
+
# "recusa boot com chave desconhecida, no silent config compat"). Raised by
|
|
187
|
+
# EnvSchema.enforce! at boot ONLY when strictness is on (INSIKA_CONFIG_STRICT) —
|
|
188
|
+
# by default a bad key WARNS and the engine still boots (last-known-good: a rotated
|
|
189
|
+
# env or a typo never takes the whole service down). `findings` carries the
|
|
190
|
+
# per-key detail (EnvSchema::Finding) so the operator can fix the config.
|
|
191
|
+
class ConfigError < Error
|
|
192
|
+
attr_reader :findings
|
|
193
|
+
|
|
194
|
+
def initialize(message = nil, findings: [])
|
|
195
|
+
@findings = findings || []
|
|
196
|
+
detail = @findings.map { |f| f.respond_to?(:message) ? f.message : f.to_s }.join("; ")
|
|
197
|
+
base = message || "strict config check failed"
|
|
198
|
+
super(detail.empty? ? base : "#{base}: #{detail}")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|