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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# WS1: issues a PER-TENANT token (multi_tenant mode). Operator-only BY
|
|
6
|
+
# CONSTRUCTION: the edge refuses a tenant principal on POST /v1/commands
|
|
7
|
+
# (403), and an internal command stamped with a tenant is refused here —
|
|
8
|
+
# a tenant can never mint credentials. The plaintext token is the response
|
|
9
|
+
# and exists nowhere else; the store keeps only its hash. -> Issue.to_h.
|
|
10
|
+
class IssueTenantToken
|
|
11
|
+
def initialize(token_store:, event_stream:)
|
|
12
|
+
@token_store = token_store
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
|
|
18
|
+
|
|
19
|
+
tenant_id = Insika::Coercion.presence(
|
|
20
|
+
command.payload[:tenant_id] || command.payload["tenant_id"]
|
|
21
|
+
)
|
|
22
|
+
raise Insika::ValidationError, "tenant_id is required" if tenant_id.nil?
|
|
23
|
+
|
|
24
|
+
label = command.payload[:label] || command.payload["label"] || "default"
|
|
25
|
+
issue = @token_store.issue(tenant_id: tenant_id, label: label)
|
|
26
|
+
emit(issue.id, tenant_id)
|
|
27
|
+
{ token: issue.token, id: issue.id, tenant_id: tenant_id, label: label.to_s }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def emit(token_id, tenant_id)
|
|
33
|
+
@event_stream.emit(Insika::Event.new(
|
|
34
|
+
type: :tenant_token_issued,
|
|
35
|
+
data: { token_id: token_id, tenant_id: tenant_id },
|
|
36
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
37
|
+
))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -4,7 +4,7 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
|
-
# Control command
|
|
7
|
+
# Control command: the human's answer to a gated proposal.
|
|
8
8
|
#
|
|
9
9
|
# approve -> each edit is written through `AgentFileStore#write`, which pushes
|
|
10
10
|
# the previous content into `history`. Rollback is therefore already
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# WS1: revokes ONE token by id. The edge resolves tokens by hash, so the
|
|
6
|
+
# plaintext is not needed to kill a credential. Idempotent: revoking an
|
|
7
|
+
# already-revoked or unknown id -> { revoked: false }, never an error.
|
|
8
|
+
# -> { id:, revoked: bool }.
|
|
9
|
+
class RevokeToken
|
|
10
|
+
def initialize(token_store:, event_stream:)
|
|
11
|
+
@token_store = token_store
|
|
12
|
+
@event_stream = event_stream
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(command)
|
|
16
|
+
raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
|
|
17
|
+
|
|
18
|
+
id = Insika::Coercion.presence(
|
|
19
|
+
command.payload[:token_id] || command.payload["token_id"]
|
|
20
|
+
)
|
|
21
|
+
raise Insika::ValidationError, "token_id is required" if id.nil?
|
|
22
|
+
|
|
23
|
+
revoked = @token_store.revoke(id)
|
|
24
|
+
emit(id, revoked)
|
|
25
|
+
{ id: id, revoked: revoked }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def emit(token_id, revoked)
|
|
31
|
+
@event_stream.emit(Insika::Event.new(
|
|
32
|
+
type: :tenant_token_revoked,
|
|
33
|
+
data: { token_id: token_id, revoked: revoked },
|
|
34
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
35
|
+
))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# WS1: rotates a tenant's credential — revokes every active token of the
|
|
6
|
+
# tenant and issues a fresh one, in ONE transaction (a crashed half-rotation
|
|
7
|
+
# must never leave the tenant with nothing valid). Only the tenant's own
|
|
8
|
+
# tokens are touched; the operator token and other tenants are untouched.
|
|
9
|
+
# -> { revoked: n, token: Issue.to_h }.
|
|
10
|
+
class RotateTenantToken
|
|
11
|
+
def initialize(token_store:, event_stream:)
|
|
12
|
+
@token_store = token_store
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
|
|
18
|
+
|
|
19
|
+
tenant_id = Insika::Coercion.presence(
|
|
20
|
+
command.payload[:tenant_id] || command.payload["tenant_id"]
|
|
21
|
+
)
|
|
22
|
+
raise Insika::ValidationError, "tenant_id is required" if tenant_id.nil?
|
|
23
|
+
|
|
24
|
+
label = command.payload[:label] || command.payload["label"] || "default"
|
|
25
|
+
result = @token_store.rotate(tenant_id: tenant_id, label: label)
|
|
26
|
+
issue = result[:issue]
|
|
27
|
+
emit(issue.id, tenant_id, result[:revoked])
|
|
28
|
+
{ revoked: result[:revoked],
|
|
29
|
+
token: { token: issue.token, id: issue.id, tenant_id: tenant_id, label: label.to_s } }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def emit(token_id, tenant_id, revoked)
|
|
35
|
+
@event_stream.emit(Insika::Event.new(
|
|
36
|
+
type: :tenant_token_rotated,
|
|
37
|
+
data: { token_id: token_id, tenant_id: tenant_id, revoked: revoked },
|
|
38
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
39
|
+
))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -4,11 +4,11 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
|
-
# Control command
|
|
7
|
+
# Control command: reads a window of the agent's real traffic
|
|
8
8
|
# and records a ranked failure REPORT. Synchronous — it only scans durable stores
|
|
9
9
|
# (no provider call, no fiber), so it answers with the Run and does not create a
|
|
10
10
|
# Task. It is the ONLY way a refinement run starts: the CLI, the Studio button
|
|
11
|
-
# and any external cron all dispatch this one command (
|
|
11
|
+
# and any external cron all dispatch this one command (there is no
|
|
12
12
|
# scheduler in the engine).
|
|
13
13
|
#
|
|
14
14
|
# Payload:
|
|
@@ -23,8 +23,8 @@ module Insika
|
|
|
23
23
|
# the previous run for this agent, unless `full`) -> the agent's configured
|
|
24
24
|
# `refinement.window` -> the collector's default.
|
|
25
25
|
#
|
|
26
|
-
#
|
|
27
|
-
# `refinement` config reads as report-only. Only `propose`/`auto_apply`
|
|
26
|
+
# writes NOTHING to the agent, so it needs no opt-in: an absent
|
|
27
|
+
# `refinement` config reads as report-only. Only `propose`/`auto_apply`
|
|
28
28
|
# require the operator to enable them explicitly.
|
|
29
29
|
class RunRefinement
|
|
30
30
|
READ_ONLY_MODES = %w[report propose auto_apply].freeze
|
|
@@ -77,7 +77,7 @@ module Insika
|
|
|
77
77
|
|
|
78
78
|
# An unknown mode is a config typo, and a typo that silently degrades to
|
|
79
79
|
# "report" would be the kind of quiet wrong the strict-config rule exists to
|
|
80
|
-
# prevent
|
|
80
|
+
# prevent.
|
|
81
81
|
def validate_mode!(config)
|
|
82
82
|
mode = AgentPayload.presence(config["mode"]) || "report"
|
|
83
83
|
return if READ_ONLY_MODES.include?(mode)
|
|
@@ -7,16 +7,16 @@ module Insika
|
|
|
7
7
|
# Stream. Validations that fail do NOT create a Task
|
|
8
8
|
# (ValidationError/NotFoundError -> direct HTTP response).
|
|
9
9
|
class SendMessage
|
|
10
|
-
#
|
|
10
|
+
# surfaces whose response can carry the "you do not own the reply"
|
|
11
11
|
# verdict (`merged` for `collect`, `steered` for `steer`), and therefore the only
|
|
12
12
|
# ones where a message may join another turn. `/v1/responses` is NOT here: its body
|
|
13
13
|
# is OpenAI-shaped SSE with nowhere to put the field, and it is frozen because a
|
|
14
14
|
# live consumer speaks it. Joining a caller that cannot hear the verdict makes it
|
|
15
15
|
# deliver the same answer once per message, which is worse than not joining at all.
|
|
16
|
-
# Channels declare themselves by `channel:<id>` once
|
|
16
|
+
# Channels declare themselves by `channel:<id>` once lands.
|
|
17
17
|
COALESCABLE_TRANSPORTS = %i[http:json].freeze
|
|
18
18
|
|
|
19
|
-
#
|
|
19
|
+
# `inbound_log` is the retry window for channel event ids.
|
|
20
20
|
# nil = no dedup (every surface that does not send an `event_id`, which is all
|
|
21
21
|
# of them today), and a caller that cannot supply a stable id gets
|
|
22
22
|
# at-least-once turns rather than a content hash pretending to be dedup.
|
|
@@ -42,7 +42,7 @@ module Insika
|
|
|
42
42
|
|
|
43
43
|
# session_id XOR history (both -> error; neither -> one-shot).
|
|
44
44
|
if p[:session_id] && p[:history]
|
|
45
|
-
raise Insika::ValidationError, "session_id and history are mutually exclusive
|
|
45
|
+
raise Insika::ValidationError, "session_id and history are mutually exclusive"
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
validate_history!(p[:history]) if p[:history]
|
|
@@ -56,7 +56,7 @@ module Insika
|
|
|
56
56
|
(raise Insika::NotFoundError, "session '#{p[:session_id]}' not found")
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
#
|
|
59
|
+
# the platform retried a webhook it already delivered. Answer
|
|
60
60
|
# with the turn it ALREADY produced and run nothing: without this, one flaky
|
|
61
61
|
# ack costs a second LLM turn and sends the customer the same answer twice.
|
|
62
62
|
# Checked before the queue doors on purpose — a duplicate is not a fragment to
|
|
@@ -77,18 +77,18 @@ module Insika
|
|
|
77
77
|
def start_turn(command, p, profile)
|
|
78
78
|
message = p[:message]
|
|
79
79
|
|
|
80
|
-
#
|
|
80
|
+
# a fragment for a session whose turn is still at the door
|
|
81
81
|
# joins it instead of becoming a turn of its own. Asked BEFORE `create` so a
|
|
82
82
|
# merge leaves no orphan :queued task behind (Recovery replays :queued at
|
|
83
83
|
# boot). Only offered on a surface that can report the verdict back —
|
|
84
|
-
#
|
|
84
|
+
# coalescing a caller that cannot hear `merged` makes it deliver the
|
|
85
85
|
# same answer twice.
|
|
86
86
|
if coalescable?(command)
|
|
87
87
|
if (joined = @executor.collect_into_pending(p[:session_id], message, profile: profile))
|
|
88
88
|
return { task_id: joined, merged: true }
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
-
#
|
|
91
|
+
# the turn is already RUNNING: the message is appended to it at
|
|
92
92
|
# the next tool-batch boundary. Same verdict as a merge, different word: the
|
|
93
93
|
# answer comes out of `task_id`, which is not this call's to deliver. Asked
|
|
94
94
|
# after `collect` because the two cannot both apply — a turn is either still at
|
|
@@ -101,7 +101,7 @@ module Insika
|
|
|
101
101
|
# command.to_h persists the entire Command in the Task;
|
|
102
102
|
# ResumeTask re-reads payload.message from there.
|
|
103
103
|
task = @task_store.create(command: command.to_h, session_id: p[:session_id])
|
|
104
|
-
#
|
|
104
|
+
# `interrupt` mode: the turn in flight is now answering the wrong
|
|
105
105
|
# question, so it is abandoned at its next boundary. This message keeps its OWN
|
|
106
106
|
# task and its own reply (that is why it needs no verdict and no surface gate), and
|
|
107
107
|
# the cancel is posted after `create` so the event can name what replaced what.
|
|
@@ -6,7 +6,7 @@ module Insika
|
|
|
6
6
|
module Commands
|
|
7
7
|
# Control command: adjusts an agent's tools allow/denylist.
|
|
8
8
|
# `allow` nil = all (AgentProfile rule); `deny` always
|
|
9
|
-
# wins. `allow_groups
|
|
9
|
+
# wins. `allow_groups`: per-group allowlist, only
|
|
10
10
|
# overwritten if the key comes in the payload (otherwise preserved). Takes effect on the next
|
|
11
11
|
# dispatch (hot). -> AgentProfile.
|
|
12
12
|
class SetAgentTools
|
|
@@ -5,8 +5,15 @@ require "time"
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
7
|
# Control command: enables/disables a skill on N
|
|
8
|
-
# agents at once, adjusting each one's `profile.skills` allowlist
|
|
9
|
-
#
|
|
8
|
+
# agents at once, adjusting each one's `profile.skills` allowlist — and, with
|
|
9
|
+
# `eager_ids`, which of them keep the body in the prompt on EVERY turn
|
|
10
|
+
# (`profile.skills_eager`). Takes effect on the next dispatch (hot via
|
|
11
|
+
# ProfileSource). -> { name, enabled_for, eager_for, skipped_all, skipped_eager_all }.
|
|
12
|
+
#
|
|
13
|
+
# Both are per-agent decisions about the same skill, which is why they are one
|
|
14
|
+
# command: eagerness lives on the agent precisely BECAUSE the skill is shared, so
|
|
15
|
+
# the screen that says "which agents can load this" is the screen that says "and
|
|
16
|
+
# which of them always have it".
|
|
10
17
|
#
|
|
11
18
|
# Allowlist semantics (AgentProfile): nil = ALL, [] = none,
|
|
12
19
|
# [names] = subset. Important and deliberate consequence:
|
|
@@ -16,6 +23,11 @@ module Insika
|
|
|
16
23
|
# allowlist (destructive/surprising). These agents are left intact and
|
|
17
24
|
# go into `skipped_all`. To restrict, use an explicit :set_agent_tools/allowlist
|
|
18
25
|
# first.
|
|
26
|
+
#
|
|
27
|
+
# `skills_eager` is NOT allowlist semantics (nil = NONE, see
|
|
28
|
+
# SkillCatalog#eager_for), so the two fields are adjusted by different rules: nil
|
|
29
|
+
# there is an empty set that can simply be added to, and only the BLANKET `true`
|
|
30
|
+
# has the same "cannot remove one name from all" problem (-> skipped_eager_all).
|
|
19
31
|
class SetSkillAgents
|
|
20
32
|
def initialize(profile_source:, event_stream:)
|
|
21
33
|
@profile_source = profile_source
|
|
@@ -27,35 +39,46 @@ module Insika
|
|
|
27
39
|
name = AgentPayload.presence(p[:name])
|
|
28
40
|
raise Insika::ValidationError, "name is required" if name.nil?
|
|
29
41
|
raise Insika::ValidationError, "agent_ids must be a list" unless p[:agent_ids].nil? || p[:agent_ids].is_a?(Array)
|
|
42
|
+
raise Insika::ValidationError, "eager_ids must be a list" unless p[:eager_ids].nil? || p[:eager_ids].is_a?(Array)
|
|
30
43
|
|
|
31
44
|
wanted = Array(p[:agent_ids]).map(&:to_s)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
new_skills = next_skills(profile.skills, name, want)
|
|
38
|
-
|
|
39
|
-
if new_skills == :skip
|
|
40
|
-
skipped_all << profile.id
|
|
41
|
-
next
|
|
42
|
-
end
|
|
43
|
-
next if new_skills == profile.skills # no change
|
|
45
|
+
# eager_ids ABSENT means "this form does not manage eagerness" — leave every
|
|
46
|
+
# profile's setting alone. An empty ARRAY means "none of them", which is a real
|
|
47
|
+
# instruction and must be applied. `nil` and `[]` are not the same request.
|
|
48
|
+
eager_wanted = p[:eager_ids].nil? ? nil : Array(p[:eager_ids]).map(&:to_s)
|
|
49
|
+
result = { enabled_for: [], eager_for: [], skipped_all: [], skipped_eager_all: [] }
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
enabled_for << profile.id if want
|
|
47
|
-
end
|
|
51
|
+
@profile_source.all.each { |profile| apply(profile, name, wanted, eager_wanted, result) }
|
|
48
52
|
|
|
49
53
|
@event_stream.emit(Insika::Event.new(
|
|
50
54
|
type: :skill_agents_set,
|
|
51
|
-
data: { name: name, agent_ids: wanted,
|
|
55
|
+
data: { name: name, agent_ids: wanted, eager_ids: eager_wanted,
|
|
56
|
+
skipped_all: result[:skipped_all] }.compact,
|
|
52
57
|
meta: { at: Time.now.utc.iso8601 }
|
|
53
58
|
))
|
|
54
|
-
{ name: name
|
|
59
|
+
{ name: name }.merge(result)
|
|
55
60
|
end
|
|
56
61
|
|
|
57
62
|
private
|
|
58
63
|
|
|
64
|
+
def apply(profile, name, wanted, eager_wanted, result)
|
|
65
|
+
want = wanted.include?(profile.id)
|
|
66
|
+
new_skills = next_skills(profile.skills, name, want)
|
|
67
|
+
new_eager = next_eager(profile, name, eager_wanted, result)
|
|
68
|
+
|
|
69
|
+
if new_skills == :skip
|
|
70
|
+
result[:skipped_all] << profile.id
|
|
71
|
+
new_skills = profile.skills
|
|
72
|
+
end
|
|
73
|
+
result[:enabled_for] << profile.id if want
|
|
74
|
+
result[:eager_for] << profile.id if eager_wanted&.include?(profile.id)
|
|
75
|
+
return if new_skills == profile.skills && new_eager == profile.skills_eager
|
|
76
|
+
|
|
77
|
+
@profile_source.put(Insika::AgentProfile.build(
|
|
78
|
+
**profile.to_h.merge(skills: new_skills, skills_eager: new_eager)
|
|
79
|
+
))
|
|
80
|
+
end
|
|
81
|
+
|
|
59
82
|
# -> new allowlist | :skip (agent with nil that requested disable).
|
|
60
83
|
def next_skills(current, name, want)
|
|
61
84
|
if want
|
|
@@ -66,6 +89,24 @@ module Insika
|
|
|
66
89
|
Array(current).map(&:to_s) - [name]
|
|
67
90
|
end
|
|
68
91
|
end
|
|
92
|
+
|
|
93
|
+
# -> new skills_eager. nil eager_wanted = not managed by this call.
|
|
94
|
+
def next_eager(profile, name, eager_wanted, result)
|
|
95
|
+
current = profile.skills_eager
|
|
96
|
+
return current if eager_wanted.nil?
|
|
97
|
+
|
|
98
|
+
want = eager_wanted.include?(profile.id)
|
|
99
|
+
# Blanket `true` already includes every skill; removing ONE name from it would
|
|
100
|
+
# mean materializing the whole catalog as a list, the same destructive surprise
|
|
101
|
+
# `skipped_all` exists for.
|
|
102
|
+
if Coercion.truthy?(current)
|
|
103
|
+
result[:skipped_eager_all] << profile.id unless want
|
|
104
|
+
return current
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
list = current.nil? || current == false ? [] : Array(current).map(&:to_s)
|
|
108
|
+
want ? (list | [name]) : (list - [name])
|
|
109
|
+
end
|
|
69
110
|
end
|
|
70
111
|
end
|
|
71
112
|
end
|
|
@@ -45,7 +45,7 @@ module Insika
|
|
|
45
45
|
raise Insika::NotFoundError, "workflow '#{workflow}' not registered"
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# I/O by schema
|
|
48
|
+
# I/O by schema: the INPUT is validated SYNCHRONOUSLY —
|
|
49
49
|
# a non-conforming input is a WorkflowSchemaError (< ValidationError -> 422)
|
|
50
50
|
# with NO run created. `definition` reads the schema metadata without
|
|
51
51
|
# resolving the factory (stays out of the fiber). No-op without an input_schema.
|
|
@@ -25,7 +25,7 @@ module Insika
|
|
|
25
25
|
# what was sent. id never changes (rename = create+delete, out of scope).
|
|
26
26
|
merged = existing.to_h.merge(patch).merge(id: id)
|
|
27
27
|
profile = Insika::AgentProfile.build(**merged)
|
|
28
|
-
#
|
|
28
|
+
# definition-time cycle + depth check (an update may ADD
|
|
29
29
|
# subagents to an existing agent). Raises SubagentError before persisting.
|
|
30
30
|
validate_subagent_graph!(profile)
|
|
31
31
|
@profile_source.put(profile)
|
|
@@ -5,7 +5,7 @@ require "time"
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
7
|
# Writes (upserts) a DATA-defined tool into the ToolStore and RELOADS the overlay +
|
|
8
|
-
# catalog — takes effect without a restart
|
|
8
|
+
# catalog — takes effect without a restart. Rejects a name that collides with a
|
|
9
9
|
# code tool (R3). Validating the definition (format, method/url, types,
|
|
10
10
|
# placeholders) is the ToolStore/ToolDefinition's job. Secrets (headers) reconcile
|
|
11
11
|
# in the store; the event carries only the name (0 leakage). -> { name, definition }.
|
|
@@ -4,7 +4,7 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Commands
|
|
7
|
-
# Control command: writes an eval case
|
|
7
|
+
# Control command: writes an eval case into the GoldenStore. This
|
|
8
8
|
# is what lets a domain owner add a case — above all its RUBRIC, the part of an eval
|
|
9
9
|
# that is plain language — without a checkout and a text editor.
|
|
10
10
|
#
|
|
@@ -5,10 +5,15 @@ require "yaml"
|
|
|
5
5
|
|
|
6
6
|
module Insika
|
|
7
7
|
module Commands
|
|
8
|
-
# Control command: writes a
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# Control command: writes a skill (complete SKILL.md) into
|
|
9
|
+
# the SkillStore and RELOADS the catalog — takes effect without a restart (hot).
|
|
10
|
+
# Validates the frontmatter (name required) before writing. -> { name, agent, updated_at }.
|
|
11
|
+
#
|
|
12
|
+
# `agent` (optional) writes into that agent's scope instead of the shared one: the
|
|
13
|
+
# SPECIALIZATION of a shared skill, or a skill private to one agent. Same `name`
|
|
14
|
+
# either way — the store position is the identity, so the frontmatter inside an
|
|
15
|
+
# override keeps saying the bare shared name and the level-1 catalog, load_skill,
|
|
16
|
+
# the labels and the events all keep displaying it.
|
|
12
17
|
class WriteSkill
|
|
13
18
|
def initialize(skill_store:, skill_catalog:, event_stream:)
|
|
14
19
|
@skill_store = skill_store
|
|
@@ -19,21 +24,26 @@ module Insika
|
|
|
19
24
|
def call(command)
|
|
20
25
|
p = AgentPayload.symbolize(command.payload)
|
|
21
26
|
name = AgentPayload.presence(p[:name])
|
|
27
|
+
agent = AgentPayload.presence(p[:agent])
|
|
22
28
|
content = p[:content].to_s
|
|
23
29
|
raise Insika::ValidationError, "name is required" if name.nil?
|
|
24
30
|
validate_frontmatter!(content)
|
|
25
31
|
|
|
26
|
-
rec = @skill_store.write(name, content, create_only: !!p[:create_only])
|
|
32
|
+
rec = @skill_store.write(name, content, agent: agent, create_only: !!p[:create_only])
|
|
27
33
|
@skill_catalog.reload
|
|
34
|
+
emit(:skill_written, name, agent)
|
|
35
|
+
{ name: name, agent: agent, updated_at: rec["updated_at"] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def emit(type, name, agent)
|
|
28
41
|
@event_stream.emit(Insika::Event.new(
|
|
29
|
-
type:
|
|
42
|
+
type: type, data: { name: name, agent: agent }.compact,
|
|
30
43
|
meta: { at: Time.now.utc.iso8601 }
|
|
31
44
|
))
|
|
32
|
-
{ name: name, updated_at: rec["updated_at"] }
|
|
33
45
|
end
|
|
34
46
|
|
|
35
|
-
private
|
|
36
|
-
|
|
37
47
|
# Mirrors the SkillCatalog parse: without YAML frontmatter with `name`, the skill
|
|
38
48
|
# would be silently ignored on reload — fail early, in the Command.
|
|
39
49
|
def validate_frontmatter!(content)
|
data/lib/insika/config_store.rb
CHANGED
|
@@ -18,15 +18,19 @@ module Insika
|
|
|
18
18
|
# agent_files/skills: the CONTENT of per-agent prompts
|
|
19
19
|
# and shared skills lives in the Store (single source of truth,
|
|
20
20
|
# a SQLite backup), not on disk — disk becomes only seed/import.
|
|
21
|
-
# goldens: authored eval cases
|
|
21
|
+
# goldens: authored eval cases — config, like the
|
|
22
22
|
# prompts and skills: the corpus on disk is seed and export, the store is what a
|
|
23
23
|
# deployment runs and what the Studio edits.
|
|
24
|
-
# baselines: the ACCEPTED state of each agent's golden set
|
|
24
|
+
# baselines: the ACCEPTED state of each agent's golden set.
|
|
25
25
|
# Config for the same reason: it is a curated decision ("this is the bar"), the
|
|
26
26
|
# file is its export, and the refinement gate reads it from inside a deployment
|
|
27
27
|
# that has no checkout.
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
# agent_skills: the per-agent SPECIALIZATIONS of a shared skill (and
|
|
29
|
+
# agent-private skills). A second scope rather than a composite key in `skills`,
|
|
30
|
+
# so the shared records are untouched by the agent dimension arriving — no
|
|
31
|
+
# migration, and a live deployment keeps serving exactly what it served.
|
|
32
|
+
SCOPES = %w[agents settings llm_providers mcp agent_files skills agent_skills
|
|
33
|
+
system_files tools goldens baselines].freeze
|
|
30
34
|
|
|
31
35
|
class UnknownScope < Insika::Error; end
|
|
32
36
|
|
|
@@ -19,7 +19,7 @@ module Insika
|
|
|
19
19
|
def initialize(providers:, event_stream:, hooks: Hooks.new, estimator: TokenEstimator)
|
|
20
20
|
@providers = providers
|
|
21
21
|
@event_stream = event_stream
|
|
22
|
-
@hooks = hooks #
|
|
22
|
+
@hooks = hooks # prompt pair; empty Hooks = no-op
|
|
23
23
|
@estimator = estimator
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -97,7 +97,7 @@ module Insika
|
|
|
97
97
|
def estimable_text(content)
|
|
98
98
|
case content
|
|
99
99
|
when String then content
|
|
100
|
-
# An eviction unit (
|
|
100
|
+
# An eviction unit (R1): a cycle of message Hashes -> sum their text.
|
|
101
101
|
when Array then content.map { |c| estimable_text(c) }.join(" ")
|
|
102
102
|
when Hash then content.values.map(&:to_s).join(" ")
|
|
103
103
|
else content.to_s
|
|
@@ -8,12 +8,36 @@ module Insika
|
|
|
8
8
|
# tokens: Integer | nil; estimated by the Builder when nil
|
|
9
9
|
# source: String — provider id (audit)
|
|
10
10
|
# pinned: true -> uncuttable in the budget (e.g. identity)
|
|
11
|
+
# labels: [{ "name" =>, "reason" => }] — WHAT this fragment carries and WHY,
|
|
12
|
+
# as ids. Content-FREE by contract, so the context trace can report
|
|
13
|
+
# which skills a turn injected without storing a byte of the bodies.
|
|
14
|
+
# [] = nothing to name (the default for every provider that has no
|
|
15
|
+
# natural id, e.g. the identity prompt).
|
|
16
|
+
#
|
|
17
|
+
# The REASON is the point. A name alone answers "was something
|
|
18
|
+
# injected"; the operator's actual question is "which skill did I
|
|
19
|
+
# trigger, and why is it here" — `eager` (the agent always wants it),
|
|
20
|
+
# `trigger:<matched phrase>` (this message asked for it), or absent
|
|
21
|
+
# for a body a plugin supplied. String keys because these labels are
|
|
22
|
+
# written to the context trace and to events as JSON: the round-trip
|
|
23
|
+
# is then the identity, and no reader has to defend against both.
|
|
11
24
|
ContextFragment = Data.define(:content, :placement, :priority, :tokens,
|
|
12
|
-
:source, :pinned) do
|
|
25
|
+
:source, :pinned, :labels) do
|
|
13
26
|
def self.build(content:, placement:, source:, priority: 50, tokens: nil,
|
|
14
|
-
pinned: false)
|
|
27
|
+
pinned: false, labels: [])
|
|
15
28
|
new(content: content, placement: placement, priority: priority,
|
|
16
|
-
tokens: tokens, source: source, pinned: pinned
|
|
29
|
+
tokens: tokens, source: source, pinned: pinned,
|
|
30
|
+
labels: Array(labels).map { |l| label(l) })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A bare String is still a valid label (a provider that has an id but no reason
|
|
34
|
+
# to give) — it normalizes to a reason-less entry rather than being rejected.
|
|
35
|
+
def self.label(raw)
|
|
36
|
+
return { "name" => raw.to_s }.freeze unless raw.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
name = (raw[:name] || raw["name"]).to_s
|
|
39
|
+
reason = raw[:reason] || raw["reason"]
|
|
40
|
+
{ "name" => name, "reason" => reason&.to_s }.compact.freeze
|
|
17
41
|
end
|
|
18
42
|
end
|
|
19
43
|
end
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
module Insika
|
|
4
4
|
module Context
|
|
5
5
|
# Precedence ladder for context fragments — SINGLE SOURCE of the order
|
|
6
|
-
# (
|
|
6
|
+
# (trust boundary). Higher = more authority: appears earlier
|
|
7
7
|
# in the system prompt and survives budget cuts. Providers reference these
|
|
8
8
|
# constants instead of loose numbers, so the boundary is an auditable
|
|
9
9
|
# contract in a single place (trust_boundary_spec locks the order).
|
|
10
10
|
#
|
|
11
|
-
# Contract
|
|
11
|
+
# Contract: identity and guardrails go PINNED at the top; TURN
|
|
12
12
|
# injections (request_context — the consumer's tenant/vars) sit at the
|
|
13
13
|
# BOTTOM and are sacrificed FIRST under budget. Identity (pinned) is NEVER
|
|
14
14
|
# truncated. A prompt injection riding in via turn data is DATA, not
|
|
@@ -18,6 +18,7 @@ module Insika
|
|
|
18
18
|
module Priority
|
|
19
19
|
IDENTITY = 100 # IDENTITY/SOUL (Prompt) — pinned
|
|
20
20
|
PROMPT_REF = 90 # Prompt Catalog guardrails/refs (Prompt) — pinned
|
|
21
|
+
SKILL_BODY = 85 # <active_skill> trigger-matched body (SkillTrigger)
|
|
21
22
|
SKILL = 80 # <available_skills> level 1 (Skill)
|
|
22
23
|
MEMORY = 75 # <memory> read path (Memory)
|
|
23
24
|
TOOL_SEARCH = 70 # <available_tools> level 1 (ToolSearch)
|
|
@@ -33,7 +33,7 @@ module Insika
|
|
|
33
33
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
|
-
# Engine memory scope
|
|
36
|
+
# Engine memory scope: an EXPLICIT tenant from the Command wins
|
|
37
37
|
# (multi-merchant override); otherwise the SESSION (=chat) — engine-owner
|
|
38
38
|
# memory is per-chat. No session (one-shot) and no tenant -> nil (MemoryStore
|
|
39
39
|
# applies _default). Symmetric to the write path (`state.tenant` in the Executor).
|
|
@@ -13,7 +13,7 @@ module Insika
|
|
|
13
13
|
# "history" is transcript (consumed by the Session provider), not turn
|
|
14
14
|
# metadata — it does not leak into the system's request_context. Keys
|
|
15
15
|
# prefixed with "__" are INTERNAL slots (e.g. the per-chat model pin
|
|
16
|
-
# "__llm__"
|
|
16
|
+
# "__llm__") — reserved, never rendered to the model.
|
|
17
17
|
request.vars.to_h.each do |k, v|
|
|
18
18
|
next if k.to_s == "history" || k.to_s.start_with?("__")
|
|
19
19
|
|
|
@@ -17,7 +17,13 @@ module Insika
|
|
|
17
17
|
messages = transcript_for(request)
|
|
18
18
|
return [] if messages.nil? || messages.empty?
|
|
19
19
|
|
|
20
|
-
#
|
|
20
|
+
# A3/C3 opt-in: identical tool results in the transcript collapse to a
|
|
21
|
+
# back-reference (the cheap half of compaction). CHANGES WHAT THE MODEL
|
|
22
|
+
# SEES — hence the profile flag, never a default. Applied BEFORE the
|
|
23
|
+
# eviction-unit grouping so a cycle's results are already slim.
|
|
24
|
+
messages = compress_history(messages, request)
|
|
25
|
+
|
|
26
|
+
# 1 fragment per EVICTION UNIT (R1): a plain message, OR an
|
|
21
27
|
# assistant-with-tool_calls together with its tool results. Grouping at
|
|
22
28
|
# the fragment level means the budget cut (apply_budget) drops a whole
|
|
23
29
|
# tool cycle atomically — a tool_use is NEVER seeded without its result
|
|
@@ -72,7 +78,16 @@ module Insika
|
|
|
72
78
|
h
|
|
73
79
|
end
|
|
74
80
|
|
|
75
|
-
|
|
81
|
+
def tool_calls?(msg) = msg[:tool_calls] && !Array(msg[:tool_calls]).empty?
|
|
82
|
+
|
|
83
|
+
# The compression is opt-in per agent (profile data, config-over-code):
|
|
84
|
+
# absent/off -> the transcript passes through byte-identical (parity).
|
|
85
|
+
def compress_history(messages, request)
|
|
86
|
+
profile = request.respond_to?(:profile) ? request.profile : nil
|
|
87
|
+
return messages unless profile&.tool_output_compression
|
|
88
|
+
|
|
89
|
+
ToolOutputCompressor.compress_transcript(messages)
|
|
90
|
+
end
|
|
76
91
|
|
|
77
92
|
# Precedence: checkpoint -> explicit history -> store.
|
|
78
93
|
# The first present source wins; no merge.
|
|
@@ -13,7 +13,11 @@ module Insika
|
|
|
13
13
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# Only the skills the model still has to ASK for. An eager skill is already in
|
|
17
|
+
# the prompt in full, so advertising it here would invite a `load_skill` call
|
|
18
|
+
# that buys a duplicate — and the catalog's whole job is to describe what is
|
|
19
|
+
# NOT yet loaded. Nothing lazy left -> CatalogProvider emits no fragment.
|
|
20
|
+
def entries(request) = @catalog.lazy_for(request.profile)
|
|
17
21
|
end
|
|
18
22
|
end
|
|
19
23
|
end
|