insika 0.1.0 → 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 +69 -3
- data/README.md +1 -1
- data/bin/insika +22 -7
- data/docs/AGENTS.md +129 -5
- data/docs/CHANNELS.md +1 -1
- data/docs/CONTEXT.md +22 -5
- data/docs/DEPLOY.md +30 -10
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +1 -1
- data/docs/LOADTEST.md +3 -2
- data/docs/OBSERVABILITY.md +11 -2
- data/docs/REFINEMENT.md +6 -6
- data/docs/RELEASING.md +7 -7
- data/docs/RUNNING-LOCAL.md +1 -1
- data/docs/SECURITY.md +24 -11
- data/docs/SKILLS.md +189 -3
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/index.md +1 -1
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +89 -22
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/channel_delivery.rb +14 -11
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +3 -3
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +7 -7
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +62 -13
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +5 -3
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- 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 +5 -5
- data/lib/insika/commands/send_message.rb +9 -9
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/context/builder.rb +2 -2
- data/lib/insika/context/fragment.rb +27 -3
- data/lib/insika/context/priority.rb +3 -2
- data/lib/insika/context/providers/memory.rb +1 -1
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +5 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/doctor.rb +250 -5
- data/lib/insika/dsl/runtime.rb +12 -9
- data/lib/insika/dsl/server_boot.rb +4 -3
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +72 -15
- data/lib/insika/edge_limiter.rb +144 -6
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +13 -10
- data/lib/insika/errors.rb +61 -5
- data/lib/insika/evals/assertions.rb +12 -12
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +3 -3
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/executor.rb +423 -108
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/golden_store.rb +2 -2
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/message_origin.rb +2 -2
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/onboarding.rb +2 -2
- data/lib/insika/outbox_store.rb +2 -2
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/profile_source.rb +12 -6
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +2 -2
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +9 -9
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +2 -2
- data/lib/insika/safety/detectors.rb +5 -5
- data/lib/insika/safety/factory.rb +3 -3
- data/lib/insika/safety/input_guardrail.rb +19 -4
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +2 -2
- data/lib/insika/safety/output_validator.rb +2 -2
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/server/app.rb +223 -51
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +15 -7
- data/lib/insika/server/responses.rb +18 -8
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +3 -3
- data/lib/insika/session_store.rb +1 -1
- data/lib/insika/settings_store.rb +5 -5
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +127 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +1 -1
- data/lib/insika/studio/app.rb +183 -61
- data/lib/insika/studio/assets/dist/application.js +25 -24
- data/lib/insika/studio/forms.rb +13 -18
- data/lib/insika/studio/nav_icons.rb +1 -1
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +2 -2
- data/lib/insika/studio/views/agents.erb +1 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +78 -3
- data/lib/insika/studio/views/settings.erb +7 -12
- data/lib/insika/studio/views/skills.erb +67 -12
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +1 -1
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +27 -27
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +8 -8
- data/lib/insika/tool_envelope.rb +1 -1
- data/lib/insika/tool_manifest.rb +6 -6
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +4 -5
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +17 -13
- data/lib/insika/turn_timing.rb +2 -2
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +77 -26
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +32 -15
- metadata +19 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -4,9 +4,8 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Server
|
|
7
|
-
# OpenAI Responses edge adapter (`/v1/responses`) — the contract that
|
|
8
|
-
# OpenClaw gateway consumers already speak
|
|
9
|
-
# `CoreServices::OpenclawDispatcher`). Phase 6, Step A.
|
|
7
|
+
# OpenAI Responses edge adapter (`/v1/responses`) — the contract that
|
|
8
|
+
# OpenClaw gateway consumers already speak.
|
|
10
9
|
#
|
|
11
10
|
# PURE module (no state, no framework): (a) translates the OpenAI
|
|
12
11
|
# Responses request → `:send_message` payload; (b) maps each turn Event →
|
|
@@ -26,7 +25,7 @@ module Insika
|
|
|
26
25
|
# `origin` is the consumer declaring WHO wrote the input it is sending. It
|
|
27
26
|
# matters here more than anywhere: this adapter's `input` is a STRING the
|
|
28
27
|
# consumer already composed out of context blocks plus the customer's text
|
|
29
|
-
# (`<memoria> …`, `<
|
|
28
|
+
# (`<memoria> …`, `<store_cep_required> …`), so a transcript reader cannot
|
|
30
29
|
# tell the two apart — the first refinement run over real traffic reported 219
|
|
31
30
|
# "the customer repeated themselves" that were the engine reading its own
|
|
32
31
|
# fragment back. A consumer that sends `origin: "engine"` on a composed turn
|
|
@@ -84,8 +83,8 @@ module Insika
|
|
|
84
83
|
# The provider's reasoning. Internal unless the AGENT opted in
|
|
85
84
|
# (`edge_stream thinking: true`), which tags the event. Even then it does
|
|
86
85
|
# NOT become answer text: it gets the Responses reasoning frame, so a
|
|
87
|
-
# consumer that only accumulates `output_text` deltas —
|
|
88
|
-
#
|
|
86
|
+
# consumer that only accumulates `output_text` deltas — a dispatcher
|
|
87
|
+
# that turns them into one WhatsApp message — is unaffected,
|
|
89
88
|
# and one that renders reasoning has something to render.
|
|
90
89
|
if public_delta(event)
|
|
91
90
|
sse("response.reasoning_summary_text.delta",
|
|
@@ -108,13 +107,19 @@ module Insika
|
|
|
108
107
|
{ type: "insika.intermediate.delta", delta: event.data[:delta].to_s })
|
|
109
108
|
end
|
|
110
109
|
when :guardrail_blocked, :guardrail_flagged
|
|
111
|
-
#
|
|
110
|
+
# audit events with no OpenAI Responses counterpart. On a BLOCK
|
|
112
111
|
# the safe reply still reaches the consumer through the normal :content
|
|
113
112
|
# deltas + :task_completed path (the turn completes gracefully), so there
|
|
114
113
|
# is nothing extra to translate here — the events live in /v1/events + the
|
|
115
114
|
# Studio + the trace. Explicit (not a fall-through) to keep the closed
|
|
116
115
|
# catalog honest.
|
|
117
116
|
nil
|
|
117
|
+
when :ttft
|
|
118
|
+
# the live TTFB signal (WS6, INSIKA_TURN_TIMING opt-in): the provider's
|
|
119
|
+
# ms-to-first-token, emitted when the first content chunk arrives.
|
|
120
|
+
# Namespaced insika.* — no OpenAI Responses counterpart; unknown types
|
|
121
|
+
# are ignored, the safe failure.
|
|
122
|
+
sse("insika.ttft", { type: "insika.ttft", ttft_ms: event.data[:ttft_ms].to_i })
|
|
118
123
|
end
|
|
119
124
|
end
|
|
120
125
|
|
|
@@ -133,9 +138,14 @@ module Insika
|
|
|
133
138
|
response[:usage] = usage.reject { |k, _| k.to_s == "model" }
|
|
134
139
|
response[:model] = model if model
|
|
135
140
|
end
|
|
136
|
-
# Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING
|
|
141
|
+
# Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING). Absent
|
|
137
142
|
# by default — a non-standard sibling used only for TTFB diagnostics.
|
|
138
143
|
(timing = event.data[:timing]) && (response[:timing] = timing)
|
|
144
|
+
# WS5 stuck signal: an additive sibling the terminal frame carries when the
|
|
145
|
+
# agent ended the turn declaring it cannot proceed. Consumers that only read
|
|
146
|
+
# the OpenAI-shaped response.use it to run their escalation ("stuck" means
|
|
147
|
+
# what they decide it means, never the engine's business).
|
|
148
|
+
(outcome = event.data[:outcome]) && (response[:outcome] = outcome.to_s)
|
|
139
149
|
sse("response.completed", { type: "response.completed", response: response })
|
|
140
150
|
end
|
|
141
151
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Server
|
|
8
|
+
# Edge resolution for WS1 (multi-tenant): `Authorization: Bearer <token>` ->
|
|
9
|
+
# a principal `{ role:, tenant_id: }`, resolved BEFORE the routes. Two modes,
|
|
10
|
+
# one gate:
|
|
11
|
+
#
|
|
12
|
+
# single_tenant (default) — no token store: the classic single operator
|
|
13
|
+
# credential (config[:gateway_token]) is the only thing that resolves.
|
|
14
|
+
# multi_tenant — tokens live in the TokenStore (per-tenant + operator).
|
|
15
|
+
# A configured gateway_token STILL resolves as operator (an existing
|
|
16
|
+
# deployment switching modes keeps its credential — additive, never
|
|
17
|
+
# a second-class path).
|
|
18
|
+
#
|
|
19
|
+
# Fail-closed BY CONSTRUCTION: no store and no configured token -> :disabled
|
|
20
|
+
# (503, never open). A revoked or unknown token -> :unauthorized. Pure module,
|
|
21
|
+
# testable without a Rack env.
|
|
22
|
+
module TenantAuth
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# gateway_token: config[:gateway_token] | nil. token_store: TokenStore |
|
|
26
|
+
# nil. header: raw Authorization value.
|
|
27
|
+
# -> :disabled | :unauthorized | { role: "operator"|"tenant", tenant_id: }
|
|
28
|
+
def check(gateway_token, token_store, header)
|
|
29
|
+
# Fail-closed FIRST (the construction rule): with no store AND no
|
|
30
|
+
# configured token the gateway is DISABLED (503) however the request
|
|
31
|
+
# looks — never "401: who are you facing a door that does not exist".
|
|
32
|
+
# A token_store present means the gateway IS configured (multi_tenant),
|
|
33
|
+
# with or without the legacy gateway token.
|
|
34
|
+
return :disabled if token_store.nil? && (gateway_token.nil? || gateway_token.empty?)
|
|
35
|
+
|
|
36
|
+
provided = header.to_s[/\ABearer (.+)\z/, 1]
|
|
37
|
+
return :unauthorized if provided.nil?
|
|
38
|
+
|
|
39
|
+
if token_store
|
|
40
|
+
record = token_store.resolve(provided)
|
|
41
|
+
unless record
|
|
42
|
+
# store miss -> the legacy gateway token still resolves as operator
|
|
43
|
+
# (an existing deployment switching modes keeps its credential).
|
|
44
|
+
return :unauthorized if gateway_token.nil? || gateway_token.empty?
|
|
45
|
+
return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
|
|
46
|
+
|
|
47
|
+
return { role: "operator", tenant_id: nil }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return { role: record.role.to_s, tenant_id: record.tenant_id }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# classic mode (no store): the gateway token is the only credential.
|
|
54
|
+
# Constant-time comparison: the operator token doesn't leak via timing.
|
|
55
|
+
return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
|
|
56
|
+
|
|
57
|
+
{ role: "operator", tenant_id: nil }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/insika/session_actor.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Insika
|
|
|
17
17
|
# Executor) is also born on the supervisor; the SessionActor only AWAITS it to
|
|
18
18
|
# serialize.
|
|
19
19
|
#
|
|
20
|
-
#
|
|
20
|
+
# it is also where an inbound message for a BUSY session is routed.
|
|
21
21
|
# That decision belongs here and nowhere else — this is already the object that
|
|
22
22
|
# owns "one turn at a time for this session". Putting it in the HTTP handler
|
|
23
23
|
# would duplicate the invariant; putting it in the Executor would mix turn
|
|
@@ -45,7 +45,7 @@ module Insika
|
|
|
45
45
|
task.id
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
#
|
|
48
|
+
# merge a fragment into the turn waiting at the door.
|
|
49
49
|
# -> the task id it joined, or nil when there is nothing to merge into (no
|
|
50
50
|
# pending turn, the window has closed, or the turn already started). nil is
|
|
51
51
|
# the caller's signal to create a task of its own.
|
|
@@ -117,7 +117,7 @@ module Insika
|
|
|
117
117
|
end
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
#
|
|
120
|
+
# the debounce window. Sleeps on the LOOP's fiber, never on the
|
|
121
121
|
# request's, so the POST is acked immediately and the platform does not retry.
|
|
122
122
|
# Returns the task to run (re-read from the store when fragments merged into it,
|
|
123
123
|
# since the in-memory Task is a frozen snapshot of an older message).
|
data/lib/insika/session_store.rb
CHANGED
|
@@ -61,7 +61,7 @@ module Insika
|
|
|
61
61
|
# fiber, without a lock. Each message gets an "at" (ISO8601 UTC) if not
|
|
62
62
|
# provided. NotFoundError if the session does not exist.
|
|
63
63
|
#
|
|
64
|
-
# CONCURRENCY LIMITATION (
|
|
64
|
+
# CONCURRENCY LIMITATION (R2c): the RMW (read record -> += -> set) is
|
|
65
65
|
# atomic ONLY because the SessionActor serializes turns of the same session
|
|
66
66
|
# (one owner at a time). That serialization exists solely in SUPERVISED mode
|
|
67
67
|
# (the actor loop lives on the supervisor). Two concurrent send_message on the
|
|
@@ -13,7 +13,7 @@ module Insika
|
|
|
13
13
|
SCOPE = "settings"
|
|
14
14
|
KEY = "general"
|
|
15
15
|
|
|
16
|
-
# STRICT config, settings layer (
|
|
16
|
+
# STRICT config, settings layer (— "no silent config compat: every
|
|
17
17
|
# schema migration explicit"). The settings record carries a `schema_version`;
|
|
18
18
|
# every shape change is a numbered migration here, applied ONLY by the explicit
|
|
19
19
|
# `migrate!` (Studio settings saves never silently reinterpret old-shaped data).
|
|
@@ -31,7 +31,7 @@ module Insika
|
|
|
31
31
|
"turn_timeout" => 120,
|
|
32
32
|
"tool_timeout" => 30,
|
|
33
33
|
"compaction" => { "enabled" => false, "keep_last" => 20 },
|
|
34
|
-
# LLM config v2
|
|
34
|
+
# LLM config v2. Platform-wide model layer, resolved by the
|
|
35
35
|
# ModelResolver under an agent that pins no model of its own:
|
|
36
36
|
# default_model/default_provider -> the platform default (Chat > Agent > HERE)
|
|
37
37
|
# fallback_models -> ordered chain ["provider/model" | "model", ...] tried
|
|
@@ -42,13 +42,13 @@ module Insika
|
|
|
42
42
|
"default_provider" => nil,
|
|
43
43
|
"fallback_models" => [],
|
|
44
44
|
"utility_model" => nil,
|
|
45
|
-
# Reasoning control (
|
|
45
|
+
# Reasoning control (4-layer: Chat > Agent > Model > Global). `thinking`
|
|
46
46
|
# is the GLOBAL default (off/on/low/medium/high; nil = provider default);
|
|
47
47
|
# `model_params` is the PER-MODEL layer, a map "<provider/model>"|"<model>" ->
|
|
48
48
|
# { "thinking" => ... }. Both resolved by the ModelResolver.
|
|
49
49
|
"thinking" => nil,
|
|
50
50
|
"model_params" => {},
|
|
51
|
-
# Evals (
|
|
51
|
+
# Evals (panel by). The GRADERS are platform config, so
|
|
52
52
|
# the operator picks them in the Studio instead of remembering a CLI flag:
|
|
53
53
|
# judges -> [{ "model" =>, "provider" => }, …]. [] = deterministic
|
|
54
54
|
# asserts only (rubric'd cases read as judge_pending).
|
|
@@ -66,7 +66,7 @@ module Insika
|
|
|
66
66
|
"quorum" => 1,
|
|
67
67
|
"tolerance" => 0.05
|
|
68
68
|
},
|
|
69
|
-
# Edge limits
|
|
69
|
+
# Edge limits — the platform layer of the EdgeLimiter.
|
|
70
70
|
# nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
|
|
71
71
|
# chat_rate_window (s); agent_token_ceiling = total tokens per agent per
|
|
72
72
|
# agent_token_window (s). limit_response overrides the safe reply.
|
data/lib/insika/shutdown.rb
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# shutdown is a drain, not a kill (docs/DEPLOY.md, process model
|
|
5
|
+
# The serving arms install this around the Executor. On the first
|
|
6
6
|
# SIGTERM/SIGINT the process stops accepting new turns (`Executor#begin_drain!`
|
|
7
7
|
# — a turn arriving mid-drain is left `:queued` for the next boot's recovery)
|
|
8
8
|
# and waits up to `timeout` seconds for the in-flight ones; only then does the
|
|
9
9
|
# ordinary stop proceed. A second signal skips the wait — the operator insisting
|
|
10
10
|
# means now. Whatever the deadline abandons dies `:running` with the process and
|
|
11
11
|
# the next boot generation's task sweep replays it from its checkpoint
|
|
12
|
-
# (side-effect skip on resume is what makes that replay safe
|
|
12
|
+
# (side-effect skip on resume is what makes that replay safe).
|
|
13
13
|
#
|
|
14
14
|
# Mechanics, because trap context is narrow: the handler writes ONE byte into a
|
|
15
15
|
# self-pipe and returns. A plain watcher THREAD — not a fiber: at install time
|
|
@@ -27,7 +27,7 @@ module Insika
|
|
|
27
27
|
# traps, parks the watcher. The CALLING thread is captured as the stop target
|
|
28
28
|
# — install from the thread that runs the server.
|
|
29
29
|
#
|
|
30
|
-
# `executors:`
|
|
30
|
+
# `executors:` drains N graphs on one signal. Signals are a
|
|
31
31
|
# PROCESS concern, and `Signal.trap` keeps only the last handler — so a second
|
|
32
32
|
# `install` per graph would silently leave the earlier graphs dying mid-turn.
|
|
33
33
|
# The host installs ONCE, naming every graph it embedded. `executor:` is the
|
data/lib/insika/skill_catalog.rb
CHANGED
|
@@ -11,7 +11,16 @@ module Insika
|
|
|
11
11
|
# Consumed by the Executor (skill_catalog:) and by stage 3
|
|
12
12
|
# (effective/format_for_prompt).
|
|
13
13
|
class SkillCatalog
|
|
14
|
-
|
|
14
|
+
# Eagerness is NOT here. It used to be a frontmatter flag, i.e. a property of the
|
|
15
|
+
# SKILL — but skills are shared between agents, so one flag forced one decision
|
|
16
|
+
# onto every allowlist holding the skill. It is a property of the AGENT
|
|
17
|
+
# (`profile.skills_eager`, see #eager_for).
|
|
18
|
+
#
|
|
19
|
+
# companions: names of the skills this one cannot work without. Injecting or
|
|
20
|
+
# loading a skill brings them along, so the half-recipe state cannot be assembled —
|
|
21
|
+
# a reference table arriving without the procedure that reads it is worse than
|
|
22
|
+
# nothing, because the model then never asks for the other half.
|
|
23
|
+
Skill = Data.define(:name, :description, :path, :body, :triggers, :companions)
|
|
15
24
|
|
|
16
25
|
# roots ordered by PRECEDENCE (highest first): workspace, managed,
|
|
17
26
|
# bundled. Same name in more than one root: the first wins.
|
|
@@ -22,37 +31,93 @@ module Insika
|
|
|
22
31
|
def initialize(roots, store: nil)
|
|
23
32
|
@roots = Array(roots)
|
|
24
33
|
@store = store
|
|
25
|
-
@skills = load_all
|
|
34
|
+
@skills, @agent_skills = load_all
|
|
26
35
|
end
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
# `agent` (an agent id) resolves the AGENT SCOPE first, then the shared one — the
|
|
38
|
+
# same precedence chain the catalog already runs for store-over-disk and
|
|
39
|
+
# workspace-over-managed-over-bundled, with one more dimension.
|
|
40
|
+
#
|
|
41
|
+
# Three cases fall out of that one rule: SHARED (only the shared record exists),
|
|
42
|
+
# OVERRIDE (both exist, the agent's wins) and AGENT-PRIVATE (only the agent record
|
|
43
|
+
# exists — invisible elsewhere, and its name may collide freely).
|
|
44
|
+
#
|
|
45
|
+
# Without `agent` the shared scope is all there is, which is what every caller
|
|
46
|
+
# that has no agent in hand (the Studio's shared editor, a bare catalog) means.
|
|
47
|
+
def all(agent: nil)
|
|
48
|
+
shared = @skills
|
|
49
|
+
overrides = agent_scope(agent)
|
|
50
|
+
return shared.values if overrides.empty?
|
|
51
|
+
|
|
52
|
+
shared.merge(overrides).values
|
|
30
53
|
end
|
|
31
54
|
|
|
32
|
-
def find(name)
|
|
33
|
-
@skills[name.to_s]
|
|
55
|
+
def find(name, agent: nil)
|
|
56
|
+
agent_scope(agent)[name.to_s] || @skills[name.to_s]
|
|
34
57
|
end
|
|
35
58
|
|
|
36
59
|
# Reloads from disk + Store and SWAPS the index atomically: an
|
|
37
60
|
# authored/edited skill takes effect without a restart. A turn in progress
|
|
38
61
|
# captured @skills at dispatch, so it does not see the swap mid-flight.
|
|
39
62
|
def reload
|
|
40
|
-
@skills = load_all
|
|
63
|
+
@skills, @agent_skills = load_all
|
|
41
64
|
self
|
|
42
65
|
end
|
|
43
66
|
|
|
44
|
-
# Per-agent allowlist: nil -> all | [] -> none | [names] -> subset.
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
# Per-agent allowlist: nil -> all | [] -> none | [names] -> subset. `agent`
|
|
68
|
+
# selects WHICH body each allowed name resolves to (see #find); the allowlist is
|
|
69
|
+
# by NAME either way, so specializing a skill never touches the allowlist.
|
|
70
|
+
def effective(skills_policy, agent: nil)
|
|
71
|
+
Allowlist.filter(all(agent: agent), skills_policy) { |s| s.name }
|
|
47
72
|
end
|
|
48
73
|
|
|
74
|
+
# THE single definition of "always in the prompt", consulted by all three
|
|
75
|
+
# surfaces that must agree: the body provider (injects these), the level-1
|
|
76
|
+
# catalog (hides them) and load_skill (refuses them). Split the rule across three
|
|
77
|
+
# files and they drift — which is the failure this whole feature came from.
|
|
78
|
+
#
|
|
79
|
+
# `profile.skills_eager` — a PER-AGENT decision, so a shared skill stays shared:
|
|
80
|
+
# nil | false -> none (progressive disclosure; the default)
|
|
81
|
+
# true -> every allowed skill (blanket; only for a corpus that fits the budget)
|
|
82
|
+
# [names] -> exactly these
|
|
83
|
+
#
|
|
84
|
+
# Deliberately NOT `Allowlist.filter`: there nil means ALL, which is the safe
|
|
85
|
+
# default for `skills`/`tools_allow` where nil is "no policy". Here nil must mean
|
|
86
|
+
# NONE — an unconfigured agent waking up with every skill body on every turn is
|
|
87
|
+
# the opposite of a safe default. A name that is not in the agent's `skills`
|
|
88
|
+
# allowlist is a silent no-op here (the intersection with `effective`); `doctor`
|
|
89
|
+
# flags it, because the operator who wrote the name meant it.
|
|
90
|
+
def eager_for(profile)
|
|
91
|
+
allowed = effective(profile.skills, agent: profile.id)
|
|
92
|
+
spec = profile.skills_eager
|
|
93
|
+
return allowed if blanket?(spec)
|
|
94
|
+
return [] if spec.nil? || spec == false
|
|
95
|
+
|
|
96
|
+
names = Array(spec).map { |n| n.to_s.strip }
|
|
97
|
+
allowed.select { |s| names.include?(s.name) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The complement: what the model still has to ASK for — and therefore what the
|
|
101
|
+
# level-1 list advertises and load_skill will serve.
|
|
102
|
+
def lazy_for(profile) = effective(profile.skills, agent: profile.id) - eager_for(profile)
|
|
103
|
+
|
|
49
104
|
# Level 1: compact list injected into the system prompt. Metadata only.
|
|
50
105
|
# Receives the set already filtered by the agent.
|
|
106
|
+
#
|
|
107
|
+
# `when=` carries the skill's `triggers:` — THE ROUTING TABLE, GENERATED. What
|
|
108
|
+
# actually made activation reliable on the pilot was a hand-written companion file
|
|
109
|
+
# listing each skill with its trigger phrases, and nothing checked it against the
|
|
110
|
+
# catalog: a skill created at 11:28 was invisible to a table written the day
|
|
111
|
+
# before, and the model obeyed the table. Rendering the same information from the
|
|
112
|
+
# catalog means it cannot disagree with the allowlist — a newly allowed skill
|
|
113
|
+
# appears the moment it is allowed. Detecting that drift would have been strictly
|
|
114
|
+
# worse than removing its source.
|
|
51
115
|
def format_for_prompt(skills = all)
|
|
52
116
|
return "" if skills.empty?
|
|
53
117
|
|
|
54
118
|
entries = skills.map do |s|
|
|
55
|
-
%(
|
|
119
|
+
when_attr = Array(s.triggers).empty? ? "" : %( when="#{Array(s.triggers).join('; ')}")
|
|
120
|
+
%( <skill name="#{s.name}"#{when_attr}>#{s.description}</skill>)
|
|
56
121
|
end.join("\n")
|
|
57
122
|
|
|
58
123
|
<<~PROMPT.strip
|
|
@@ -60,13 +125,24 @@ module Insika
|
|
|
60
125
|
#{entries}
|
|
61
126
|
</available_skills>
|
|
62
127
|
|
|
63
|
-
Before
|
|
64
|
-
|
|
128
|
+
Before ANY reply or tool call: scan the skills above. If one matches
|
|
129
|
+
or is even partially relevant to the task, you MUST call
|
|
130
|
+
`load_skill("name")` FIRST and follow what it returns. Err on the
|
|
131
|
+
side of loading. Only skip when genuinely none apply.
|
|
65
132
|
PROMPT
|
|
66
133
|
end
|
|
67
134
|
|
|
68
135
|
private
|
|
69
136
|
|
|
137
|
+
# The blanket switch, tolerant of the strings a form / JSON round-trip produces
|
|
138
|
+
# ("1" from a checkbox, "true" from a pack) — same reading as
|
|
139
|
+
# AgentProfile#stream_public?. Anything else (a list, nil, false) is not blanket.
|
|
140
|
+
def blanket?(spec) = Coercion.truthy?(spec)
|
|
141
|
+
|
|
142
|
+
# An agent's override index; {} for a nil agent or one that specialized nothing.
|
|
143
|
+
def agent_scope(agent) = agent.nil? ? {} : (@agent_skills[agent.to_s] || {})
|
|
144
|
+
|
|
145
|
+
# -> [shared index, { agent_id => index }].
|
|
70
146
|
def load_all
|
|
71
147
|
found = {}
|
|
72
148
|
@roots.each do |root|
|
|
@@ -78,7 +154,7 @@ module Insika
|
|
|
78
154
|
end
|
|
79
155
|
end
|
|
80
156
|
overlay_store(found)
|
|
81
|
-
found
|
|
157
|
+
[found, load_agent_scopes]
|
|
82
158
|
end
|
|
83
159
|
|
|
84
160
|
# Store skills overlay the on-disk ones (authored > seed). Sentinel path
|
|
@@ -87,27 +163,58 @@ module Insika
|
|
|
87
163
|
return unless @store
|
|
88
164
|
|
|
89
165
|
@store.all.each do |name, content|
|
|
90
|
-
skill = parse_content(content.to_s, path: "store:#{name}")
|
|
91
|
-
found[
|
|
166
|
+
skill = parse_content(content.to_s, path: "store:#{name}", key: name)
|
|
167
|
+
found[name.to_s] = skill if skill # Store wins
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Per-agent overrides / private skills, one index per agent. A store that predates
|
|
172
|
+
# the agent dimension answers nothing here, so this is {} and every lookup falls
|
|
173
|
+
# straight through to the shared scope.
|
|
174
|
+
def load_agent_scopes
|
|
175
|
+
return {} unless @store.respond_to?(:agents)
|
|
176
|
+
|
|
177
|
+
@store.agents.each_with_object({}) do |agent, acc|
|
|
178
|
+
index = {}
|
|
179
|
+
@store.all(agent: agent).each do |name, content|
|
|
180
|
+
skill = parse_content(content.to_s, path: "store:#{agent}/#{name}", key: name)
|
|
181
|
+
index[name.to_s] = skill if skill
|
|
182
|
+
end
|
|
183
|
+
acc[agent.to_s] = index unless index.empty?
|
|
92
184
|
end
|
|
93
185
|
end
|
|
94
186
|
|
|
95
|
-
|
|
187
|
+
# `key` = THE STORE POSITION, and it wins over the frontmatter `name:`. An override
|
|
188
|
+
# authored for one agent still says `name: escalation-to-human` inside — that is
|
|
189
|
+
# deliberate, it is the same skill specialized — and indexing by the parsed name
|
|
190
|
+
# would clobber the shared record globally, which is the exact bug the agent scope
|
|
191
|
+
# exists to fix. It also makes a pack whose directory name and frontmatter name
|
|
192
|
+
# disagree resolvable: the allowlist is written from the directory.
|
|
193
|
+
def parse_content(raw, path:, key: nil)
|
|
96
194
|
match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
|
|
97
195
|
return nil unless match
|
|
98
196
|
|
|
99
197
|
# Tolerant frontmatter: real packs have `: ` in the description prose, which
|
|
100
198
|
# strict YAML rejected (the pack would not load).
|
|
101
199
|
meta = Insika::Frontmatter.parse(match[1])
|
|
102
|
-
name = meta["name"]
|
|
103
|
-
return nil unless name
|
|
200
|
+
name = key || meta["name"]
|
|
201
|
+
return nil unless name && Coercion.present?(meta["name"])
|
|
104
202
|
|
|
105
203
|
Skill.new(
|
|
106
204
|
name: name.to_s,
|
|
107
205
|
description: meta["description"].to_s,
|
|
108
206
|
path: path,
|
|
109
|
-
body: match[2].strip
|
|
207
|
+
body: match[2].strip,
|
|
208
|
+
triggers: parse_list(meta["triggers"]),
|
|
209
|
+
companions: parse_list(meta["companions"])
|
|
110
210
|
)
|
|
111
211
|
end
|
|
212
|
+
|
|
213
|
+
# `triggers:` / `companions:` frontmatter. YAML list, or comma-separated string
|
|
214
|
+
# under the lenient parse (which yields the whole value as one String).
|
|
215
|
+
def parse_list(raw)
|
|
216
|
+
list = raw.is_a?(String) ? raw.split(",") : Array(raw)
|
|
217
|
+
list.map { |t| t.to_s.strip }.reject(&:empty?)
|
|
218
|
+
end
|
|
112
219
|
end
|
|
113
220
|
end
|
data/lib/insika/skill_store.rb
CHANGED
|
@@ -3,20 +3,37 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
|
-
# AUTHORED
|
|
6
|
+
# AUTHORED skills, in two scopes.
|
|
7
7
|
# Holds the complete SKILL.md (frontmatter + body) in the durable Store. The
|
|
8
8
|
# SkillCatalog overlays these skills on top of the on-disk ones (seed), with the Store
|
|
9
9
|
# winning — so editing/creating a skill in the Studio takes effect without a restart (via reload).
|
|
10
10
|
#
|
|
11
|
-
#
|
|
11
|
+
# SHARED scope (`agent:` omitted) — one record per skill in the ConfigStore
|
|
12
|
+
# (scope "skills"), keyed by the skill name:
|
|
12
13
|
# { "content" => "<entire SKILL.md>",
|
|
13
14
|
# "updated_at" => iso8601,
|
|
14
15
|
# "history" => [ { "content" =>, "at" => }, ... ] }
|
|
15
16
|
#
|
|
16
|
-
#
|
|
17
|
-
# the AgentFileStore
|
|
17
|
+
# AGENT scope (`agent:` given) — one record per AGENT (scope "agent_skills"), the
|
|
18
|
+
# skills nested under it, exactly the AgentFileStore shape:
|
|
19
|
+
# { "skills" => { "<name>" => { "content" =>, "updated_at" =>, "history" => [] } } }
|
|
20
|
+
#
|
|
21
|
+
# The agent dimension is a SECOND ARGUMENT, never part of the key. A composite
|
|
22
|
+
# `"agent/name"` key would put a `/` inside what the Studio serves as a single path
|
|
23
|
+
# segment (`GET /skills/:name`, the editor, the versions list) — the class of route
|
|
24
|
+
# bug that ships green and 404s in production. Two arguments become two route
|
|
25
|
+
# segments (`/agents/:id/skills/:name`) and nothing needs encoding.
|
|
26
|
+
#
|
|
27
|
+
# Two scopes and not one: the shared records are untouched by the arrival of the
|
|
28
|
+
# agent dimension, so there is no migration and a live deployment keeps serving
|
|
29
|
+
# exactly what it served.
|
|
30
|
+
#
|
|
31
|
+
# THE STORE POSITION IS THE IDENTITY. Which scope a record sits in — and under which
|
|
32
|
+
# key — is what decides which skill it is; the frontmatter `name:` inside an override
|
|
33
|
+
# stays the bare shared name. See SkillCatalog#find.
|
|
18
34
|
class SkillStore
|
|
19
35
|
SCOPE = "skills"
|
|
36
|
+
AGENT_SCOPE = "agent_skills"
|
|
20
37
|
HISTORY_MAX = 20
|
|
21
38
|
|
|
22
39
|
def initialize(config_store:)
|
|
@@ -24,48 +41,79 @@ module Insika
|
|
|
24
41
|
end
|
|
25
42
|
|
|
26
43
|
# -> String | nil (complete SKILL.md).
|
|
27
|
-
def get(name)
|
|
28
|
-
record(name)&.fetch("content", nil)
|
|
44
|
+
def get(name, agent: nil)
|
|
45
|
+
record(name, agent)&.fetch("content", nil)
|
|
29
46
|
end
|
|
30
47
|
|
|
31
|
-
# -> [String] names, lexicographic order.
|
|
32
|
-
def names
|
|
48
|
+
# -> [String] names in the scope, lexicographic order.
|
|
49
|
+
def names(agent: nil)
|
|
50
|
+
agent.nil? ? @cs.keys(SCOPE) : agent_skills(agent).keys.sort
|
|
51
|
+
end
|
|
33
52
|
|
|
34
|
-
# -> { name => content } of
|
|
35
|
-
def all
|
|
36
|
-
|
|
53
|
+
# -> { name => content } of the scope's authored skills.
|
|
54
|
+
def all(agent: nil)
|
|
55
|
+
names(agent: agent).each_with_object({}) { |n, acc| acc[n] = get(n, agent: agent) }
|
|
37
56
|
end
|
|
38
57
|
|
|
58
|
+
# -> [String] every agent that has specialized at least one skill. What the
|
|
59
|
+
# catalog overlays and `doctor` sweeps.
|
|
60
|
+
def agents = @cs.keys(AGENT_SCOPE).sort
|
|
61
|
+
|
|
39
62
|
# Writes (upsert). create_only refuses to overwrite. -> Hash (the stored record).
|
|
40
|
-
def write(name, content, create_only: false)
|
|
63
|
+
def write(name, content, agent: nil, create_only: false)
|
|
41
64
|
key = name.to_s
|
|
42
|
-
current =
|
|
43
|
-
raise Insika::ValidationError, "skill '#{key}' already exists" if create_only && current
|
|
65
|
+
current = record(key, agent)
|
|
66
|
+
raise Insika::ValidationError, "skill '#{key}' already exists#{" for agent '#{agent}'" if agent}" if create_only && current
|
|
44
67
|
|
|
45
68
|
rec = build_record(content.to_s, current)
|
|
46
|
-
|
|
69
|
+
put(key, rec, agent)
|
|
47
70
|
rec
|
|
48
71
|
end
|
|
49
72
|
|
|
50
73
|
# -> bool (did it exist?).
|
|
51
|
-
def delete(name
|
|
74
|
+
def delete(name, agent: nil)
|
|
75
|
+
key = name.to_s
|
|
76
|
+
return @cs.delete(SCOPE, key) if agent.nil?
|
|
77
|
+
|
|
78
|
+
wrapper = @cs.get(AGENT_SCOPE, agent.to_s)
|
|
79
|
+
return false unless wrapper&.dig("skills", key)
|
|
80
|
+
|
|
81
|
+
wrapper["skills"].delete(key)
|
|
82
|
+
@cs.put(AGENT_SCOPE, agent.to_s, wrapper)
|
|
83
|
+
true
|
|
84
|
+
end
|
|
52
85
|
|
|
53
86
|
# -> [ { "content" =>, "at" => } ] most recent first.
|
|
54
|
-
def versions(name) = record(name)&.fetch("history", []) || []
|
|
87
|
+
def versions(name, agent: nil) = record(name, agent)&.fetch("history", []) || []
|
|
55
88
|
|
|
56
89
|
# Restores version `index` as the current content (a new write). -> Hash.
|
|
57
|
-
def restore(name, index)
|
|
58
|
-
hist = versions(name)
|
|
90
|
+
def restore(name, index, agent: nil)
|
|
91
|
+
hist = versions(name, agent: agent)
|
|
59
92
|
i = Integer(index)
|
|
60
|
-
raise Insika::NotFoundError, "skill '#{name}' not found" unless record(name)
|
|
93
|
+
raise Insika::NotFoundError, "skill '#{name}' not found" unless record(name.to_s, agent)
|
|
61
94
|
raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
|
|
62
95
|
|
|
63
|
-
write(name, hist[i]["content"])
|
|
96
|
+
write(name, hist[i]["content"], agent: agent)
|
|
64
97
|
end
|
|
65
98
|
|
|
66
99
|
private
|
|
67
100
|
|
|
68
|
-
def record(name
|
|
101
|
+
def record(name, agent)
|
|
102
|
+
return @cs.get(SCOPE, name.to_s) if agent.nil?
|
|
103
|
+
|
|
104
|
+
agent_skills(agent)[name.to_s]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def put(key, rec, agent)
|
|
108
|
+
return @cs.put(SCOPE, key, rec) if agent.nil?
|
|
109
|
+
|
|
110
|
+
wrapper = @cs.get(AGENT_SCOPE, agent.to_s) || { "skills" => {} }
|
|
111
|
+
wrapper["skills"] ||= {}
|
|
112
|
+
wrapper["skills"][key] = rec
|
|
113
|
+
@cs.put(AGENT_SCOPE, agent.to_s, wrapper)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def agent_skills(agent) = (@cs.get(AGENT_SCOPE, agent.to_s) || {})["skills"] || {}
|
|
69
117
|
|
|
70
118
|
def build_record(content, current)
|
|
71
119
|
history = current ? current.fetch("history", []) : []
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
#
|
|
4
|
+
# WHERE a message that arrived mid-run is allowed to enter the
|
|
5
5
|
# conversation.
|
|
6
6
|
#
|
|
7
7
|
# A customer who corrects themselves while the agent is calling tools ("1234567",
|
data/lib/insika/store.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Insika
|
|
|
5
5
|
# Namespace-scoped KV, transactional when the backend supports it.
|
|
6
6
|
# Every implementation passes the SAME contract suite
|
|
7
7
|
# (lib/insika/testing/store_contract.rb — requirable from outside the repo,
|
|
8
|
-
#
|
|
8
|
+
# Values must be JSON-serializable.
|
|
9
9
|
#
|
|
10
10
|
# scope: String — separates domains/tenants (e.g. "sessions", "tasks:tenant_x")
|
|
11
11
|
# key: Hierarchical String (e.g. "task:123", "checkpoint:123:turn:4")
|