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,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Source of AgentProfiles. Profiles used to be
|
|
5
|
+
# a FROZEN Hash injected into the Executor and the turn Commands — static,
|
|
6
|
+
# defined in Ruby at wiring time. For the Studio to create/edit agents at
|
|
7
|
+
# runtime, the source needs to be MUTABLE and reloadable, without changing the
|
|
8
|
+
# consumption contract.
|
|
9
|
+
#
|
|
10
|
+
# The consumption contract is minimal and duck-typed: `source[id] -> AgentProfile|nil`
|
|
11
|
+
# (like a Hash). That's why the refactor in the Executor/Commands is just
|
|
12
|
+
# normalizing the input (legacy Hash -> StaticProfileSource); the bodies that do
|
|
13
|
+
# `@profiles[agent]` stay identical. `all`/`ids` are for the Studio to list.
|
|
14
|
+
#
|
|
15
|
+
# `nil` from `[]`/`fetch` = agent not configured (the Commands raise
|
|
16
|
+
# NotFoundError) — NEVER raises (unlike Hash#fetch).
|
|
17
|
+
module ProfileSource
|
|
18
|
+
# Hash-compat sugar: `source[id]`.
|
|
19
|
+
def [](id) = fetch(id)
|
|
20
|
+
|
|
21
|
+
# Normalizes the consumers' input: a legacy Hash becomes a StaticProfileSource;
|
|
22
|
+
# a ProfileSource passes straight through. A single place for the compat seam.
|
|
23
|
+
def self.coerce(profiles)
|
|
24
|
+
return profiles if profiles.is_a?(ProfileSource)
|
|
25
|
+
|
|
26
|
+
StaticProfileSource.new(profiles || {})
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# subclasses implement: fetch(id) -> AgentProfile|nil, all -> [AgentProfile], ids -> [String]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Static source (parity): wraps the usual {id => AgentProfile} Hash.
|
|
33
|
+
# Behavior IDENTICAL to the frozen Hash — zero regression.
|
|
34
|
+
class StaticProfileSource
|
|
35
|
+
include ProfileSource
|
|
36
|
+
|
|
37
|
+
def initialize(profiles = {})
|
|
38
|
+
@profiles = profiles
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fetch(id) = @profiles[id]
|
|
42
|
+
def all = @profiles.values
|
|
43
|
+
def ids = @profiles.keys
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Persisted source (Studio): reads/writes profiles in the ConfigStore (scope "agents").
|
|
47
|
+
# Each `fetch` reads FRESH from the store — an edit via the Studio takes effect on
|
|
48
|
+
# the next dispatch, without a restart. An in-flight turn keeps the profile it
|
|
49
|
+
# captured (the Commands resolve at the start of #call), so the turn semantics are
|
|
50
|
+
# preserved.
|
|
51
|
+
class StoredProfileSource
|
|
52
|
+
include ProfileSource
|
|
53
|
+
include Coercion
|
|
54
|
+
|
|
55
|
+
SCOPE = "agents"
|
|
56
|
+
|
|
57
|
+
def initialize(config_store:)
|
|
58
|
+
@cs = config_store
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fetch(id)
|
|
62
|
+
record = @cs.get(SCOPE, id.to_s)
|
|
63
|
+
record && deserialize(record)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def all = @cs.all(SCOPE).map { |r| deserialize(r) }
|
|
67
|
+
def ids = @cs.keys(SCOPE)
|
|
68
|
+
|
|
69
|
+
# Write (used by the :create_agent/:update_agent Commands).
|
|
70
|
+
def put(profile)
|
|
71
|
+
@cs.put(SCOPE, profile.id, profile.to_h)
|
|
72
|
+
profile
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def delete(id) = @cs.delete(SCOPE, id.to_s)
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Rebuilds the AgentProfile from the record (the JSON round-trip turns symbols
|
|
80
|
+
# into strings). Re-symbolizes the fields the runtime consumes as symbols:
|
|
81
|
+
# provider, policies (names in the PolicyRegistry) and the limits keys (
|
|
82
|
+
# DEFAULT_LIMITS uses symbols and the merge would break with string keys).
|
|
83
|
+
def deserialize(record)
|
|
84
|
+
h = symbolize_top(record)
|
|
85
|
+
AgentProfile.build(
|
|
86
|
+
id: h[:id], model: h[:model],
|
|
87
|
+
provider: presence(h[:provider])&.to_sym,
|
|
88
|
+
base_prompt: h[:base_prompt].to_s,
|
|
89
|
+
prompt_files: h[:prompt_files] || [],
|
|
90
|
+
tools_allow: h[:tools_allow], tools_deny: h[:tools_deny] || [],
|
|
91
|
+
tools_allow_groups: h[:tools_allow_groups],
|
|
92
|
+
skills: h[:skills],
|
|
93
|
+
skills_eager: h[:skills_eager],
|
|
94
|
+
context_providers: h[:context_providers],
|
|
95
|
+
workflows_allow: h[:workflows_allow],
|
|
96
|
+
policies: Array(h[:policies]).map(&:to_sym),
|
|
97
|
+
prompt_refs: h[:prompt_refs] || [],
|
|
98
|
+
limits: symbolize_limits(h[:limits]),
|
|
99
|
+
approvals_required: h[:approvals_required],
|
|
100
|
+
capabilities: h[:capabilities],
|
|
101
|
+
# subagents: allowlist of child ids; build re-normalizes to
|
|
102
|
+
# [String]. nil round-trips as nil (opt-in: NONE).
|
|
103
|
+
subagents: h[:subagents],
|
|
104
|
+
tools_deferred: h[:tools_deferred],
|
|
105
|
+
memory: h[:memory],
|
|
106
|
+
prompt_caching: h[:prompt_caching],
|
|
107
|
+
tool_output_compression: h[:tool_output_compression],
|
|
108
|
+
# params/model_policy: the resolver tolerates string keys from
|
|
109
|
+
# the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
|
|
110
|
+
# re-symbolization needed here.
|
|
111
|
+
params: h[:params] || {},
|
|
112
|
+
model_policy: h[:model_policy],
|
|
113
|
+
budget: h[:budget],
|
|
114
|
+
reliability: h[:reliability],
|
|
115
|
+
alerts: h[:alerts],
|
|
116
|
+
stuck_signal: h[:stuck_signal],
|
|
117
|
+
# guardrails: a plain Hash; Safety::Config tolerates the JSON
|
|
118
|
+
# round-trip (string keys/values), so no re-symbolization here.
|
|
119
|
+
guardrails: h[:guardrails],
|
|
120
|
+
# sandbox: a plain config Hash; Sandbox.build tolerates the JSON
|
|
121
|
+
# round-trip (string keys), so no re-symbolization here. nil = absent.
|
|
122
|
+
sandbox: h[:sandbox],
|
|
123
|
+
# refinement: a plain config Hash read with string keys by the
|
|
124
|
+
# RunRefinement handler; nil round-trips as nil (= report-only).
|
|
125
|
+
refinement: h[:refinement],
|
|
126
|
+
# capabilities_declared: flat [String]; build re-normalizes.
|
|
127
|
+
capabilities_declared: h[:capabilities_declared],
|
|
128
|
+
# edge_stream: which internal channels may cross to the customer. {} = neither.
|
|
129
|
+
edge_stream: h[:edge_stream],
|
|
130
|
+
metadata: h[:metadata] || {}
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def symbolize_top(record) = record.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
135
|
+
|
|
136
|
+
# limits keys -> symbol; numeric values preserved by JSON.
|
|
137
|
+
def symbolize_limits(limits)
|
|
138
|
+
return {} if limits.nil?
|
|
139
|
+
|
|
140
|
+
limits.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Catalog of prompts: NON-executable content
|
|
7
|
+
# (it is a Catalog, not a Registry). Mirrors the SkillCatalog: each prompt is a directory
|
|
8
|
+
# with a PROMPT.md (YAML frontmatter name/description + markdown body).
|
|
9
|
+
# Source for the Prompt provider when the profile uses prompt_refs.
|
|
10
|
+
class PromptCatalog
|
|
11
|
+
Prompt = Data.define(:name, :description, :path, :body)
|
|
12
|
+
|
|
13
|
+
# roots ordered by PRECEDENCE (highest first) — same name in more than
|
|
14
|
+
# one root: the first wins (identical to SkillCatalog).
|
|
15
|
+
def initialize(roots)
|
|
16
|
+
@roots = Array(roots)
|
|
17
|
+
@prompts = load_all
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def all = @prompts.values
|
|
21
|
+
|
|
22
|
+
# -> Prompt | nil (the Prompt provider converts nil into a ContextError; it is not
|
|
23
|
+
# the catalog's job to raise).
|
|
24
|
+
def find(name) = @prompts[name.to_s]
|
|
25
|
+
|
|
26
|
+
# Rescan + atomic index swap: parity with the SkillCatalog
|
|
27
|
+
# for reload without a restart when the on-disk seed changes.
|
|
28
|
+
def reload
|
|
29
|
+
@prompts = load_all
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def load_all
|
|
36
|
+
found = {}
|
|
37
|
+
@roots.each do |root|
|
|
38
|
+
Dir.glob(File.join(root, "**", "PROMPT.md")).sort.each do |file|
|
|
39
|
+
prompt = parse(file)
|
|
40
|
+
next unless prompt
|
|
41
|
+
|
|
42
|
+
found[prompt.name] ||= prompt # precedence: first root wins
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
found
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse(file)
|
|
49
|
+
raw = File.read(file, encoding: "UTF-8")
|
|
50
|
+
match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
|
|
51
|
+
return nil unless match
|
|
52
|
+
|
|
53
|
+
meta = YAML.safe_load(match[1]) || {}
|
|
54
|
+
name = meta["name"]
|
|
55
|
+
return nil unless name
|
|
56
|
+
|
|
57
|
+
Prompt.new(name: name.to_s, description: meta["description"].to_s,
|
|
58
|
+
path: file, body: match[2].strip)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Classifies provider/transport failures by ACTION (B9). Four kinds —
|
|
5
|
+
# the hermes/openclaw structural rule: NON-retryable checked first, so an
|
|
6
|
+
# error we do not recognize defaults to :fatal (a retry would hammer a
|
|
7
|
+
# poisoned credential; a fatal is retried only after the operator fixes the
|
|
8
|
+
# cause):
|
|
9
|
+
#
|
|
10
|
+
# :fatal 401/402/403/400 (auth, billing, permanent quota,
|
|
11
|
+
# bad request, context too long) — retrying does not help.
|
|
12
|
+
# :retryable 5xx/529/socket/timeout — the same call may succeed
|
|
13
|
+
# moments later.
|
|
14
|
+
# :rate_limited_short a 429 that says "back off briefly" (RPM-scale).
|
|
15
|
+
# :rate_limited_long a 429 with a long retry-after — quota-scale.
|
|
16
|
+
#
|
|
17
|
+
# The classification is STRING-based (class names, no constant references):
|
|
18
|
+
# the core loads without ruby_llm, and the smoke-shim's fake RubyLLM is a
|
|
19
|
+
# drop-in. `retry_after` is read from the provider's own Retry-After header
|
|
20
|
+
# when the error carries a response, else a per-kind default.
|
|
21
|
+
class ProviderErrorClassifier
|
|
22
|
+
Classification = Data.define(:kind, :retryable, :retry_after) do
|
|
23
|
+
# the additive envelope fields — compacted so a retry_after-less fatal
|
|
24
|
+
# never invents one.
|
|
25
|
+
def to_h
|
|
26
|
+
{ kind: kind, retryable: retryable, retry_after: retry_after }.compact
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
KINDS = %i[fatal retryable rate_limited_short rate_limited_long].freeze
|
|
31
|
+
|
|
32
|
+
# Above this a 429 means quota, not RPM (this is what tells the two apart —
|
|
33
|
+
# a short 429 wants a quick retry; a long one is a billing event).
|
|
34
|
+
SHORT_RETRY_LIMIT = 60 # seconds
|
|
35
|
+
|
|
36
|
+
DEFAULTS = {
|
|
37
|
+
retryable: 5,
|
|
38
|
+
rate_limited_short: 10,
|
|
39
|
+
rate_limited_long: 300
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# RubyLLM's taxonomy (error.rb), matched by class name so the core stays
|
|
43
|
+
# ruby_llm-free at load time.
|
|
44
|
+
FATAL_ERROR_NAMES = %w[
|
|
45
|
+
RubyLLM::ContextLengthExceededError RubyLLM::BadRequestError
|
|
46
|
+
RubyLLM::UnauthorizedError RubyLLM::PaymentRequiredError RubyLLM::ForbiddenError
|
|
47
|
+
].freeze
|
|
48
|
+
RATE_LIMITED_ERROR_NAME = "RubyLLM::RateLimitError"
|
|
49
|
+
RETRYABLE_ERROR_NAMES = %w[
|
|
50
|
+
RubyLLM::ServerError RubyLLM::ServiceUnavailableError RubyLLM::OverloadedError
|
|
51
|
+
].freeze
|
|
52
|
+
RUBY_LLM_ERROR_NAMES = (
|
|
53
|
+
FATAL_ERROR_NAMES + [RATE_LIMITED_ERROR_NAME] +
|
|
54
|
+
RETRYABLE_ERROR_NAMES + ["RubyLLM::Error"]
|
|
55
|
+
).freeze
|
|
56
|
+
|
|
57
|
+
# Transport failures while talking to the provider: connection
|
|
58
|
+
# refused/reset, DNS, TLS, timeouts (Faraday wraps its own names; the
|
|
59
|
+
# stdlib ones surface from raw sockets).
|
|
60
|
+
TRANSPORT_NAME_PATTERNS = [
|
|
61
|
+
/\AFaraday::/,
|
|
62
|
+
/\ASocketError\z/,
|
|
63
|
+
/\AIOError\z/,
|
|
64
|
+
/\AErrno::/,
|
|
65
|
+
/\ANet::(Read|Open)Timeout\z/,
|
|
66
|
+
/\ATimeout::Error\z/,
|
|
67
|
+
/\AOpenSSL::SSL::SSLError\z/
|
|
68
|
+
].freeze
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
# -> Classification
|
|
72
|
+
def classify(error)
|
|
73
|
+
names = class_names(error)
|
|
74
|
+
|
|
75
|
+
# Non-retryable first (the structural rule): a known fatal is NEVER
|
|
76
|
+
# retried, and an unknown error defaults to fatal — never to retry.
|
|
77
|
+
return fatal if (names & FATAL_ERROR_NAMES).any?
|
|
78
|
+
|
|
79
|
+
return rate_limited(error) if names.include?(RATE_LIMITED_ERROR_NAME)
|
|
80
|
+
|
|
81
|
+
return retryable if (names & RETRYABLE_ERROR_NAMES).any?
|
|
82
|
+
return retryable if transport?(names)
|
|
83
|
+
|
|
84
|
+
# A generic RubyLLM::Error (or a raw HTTP error) still carries the
|
|
85
|
+
# status: 429 and 5xx are retryable regardless of the wrapping class.
|
|
86
|
+
case http_status(error)
|
|
87
|
+
when 429 then rate_limited(error)
|
|
88
|
+
when 500..599 then retryable
|
|
89
|
+
else fatal
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# True when the error came from the provider call itself (RubyLLM
|
|
94
|
+
# family or transport) — the executor routes these to the :ruby_llm
|
|
95
|
+
# stage with a wrapped classification instead of :unknown.
|
|
96
|
+
def provider_error?(error)
|
|
97
|
+
names = class_names(error)
|
|
98
|
+
(names & RUBY_LLM_ERROR_NAMES).any? || transport?(names)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The typed ProviderError the executor stores and emits.
|
|
102
|
+
def wrap(error)
|
|
103
|
+
c = classify(error)
|
|
104
|
+
Insika::ProviderError.new(
|
|
105
|
+
error.message || error.class.name,
|
|
106
|
+
kind: c.kind, retryable: c.retryable, retry_after: c.retry_after
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def fatal
|
|
113
|
+
Classification.new(kind: :fatal, retryable: false, retry_after: nil)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def retryable
|
|
117
|
+
Classification.new(kind: :retryable, retryable: true,
|
|
118
|
+
retry_after: DEFAULTS[:retryable])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def rate_limited(error)
|
|
122
|
+
ra = retry_after_header(error)
|
|
123
|
+
if ra && ra > SHORT_RETRY_LIMIT
|
|
124
|
+
Classification.new(kind: :rate_limited_long, retryable: true, retry_after: ra)
|
|
125
|
+
else
|
|
126
|
+
Classification.new(kind: :rate_limited_short, retryable: true,
|
|
127
|
+
retry_after: ra || DEFAULTS[:rate_limited_short])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def class_names(error)
|
|
132
|
+
([error.class.name] + Array(error.class.ancestors).map(&:name)).compact
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def transport?(names)
|
|
136
|
+
names.any? { |n| TRANSPORT_NAME_PATTERNS.any? { |p| p.match?(n) } }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The provider's own Retry-After (seconds), when the error carries a
|
|
140
|
+
# response. All access guarded — a bare double must not raise.
|
|
141
|
+
def retry_after_header(error)
|
|
142
|
+
headers = response_headers(error)
|
|
143
|
+
value = headers && (headers["retry-after"] || headers["Retry-After"])
|
|
144
|
+
value = value.to_s.strip
|
|
145
|
+
value.match?(/\A\d+\z/) ? value.to_i : nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def http_status(error)
|
|
149
|
+
response = error.respond_to?(:response) ? error.response : nil
|
|
150
|
+
status = response && response.respond_to?(:status) ? response.status : nil
|
|
151
|
+
status&.to_i
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def response_headers(error)
|
|
155
|
+
response = error.respond_to?(:response) ? error.response : nil
|
|
156
|
+
response && response.respond_to?(:headers) ? response.headers : nil
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "coercion"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# what happens to an inbound message for a session that is ALREADY
|
|
7
|
+
# busy. Today the engine has exactly one answer, "it waits in line"; this names
|
|
8
|
+
# that answer `followup` and adds three others:
|
|
9
|
+
#
|
|
10
|
+
# collect — the message arrived BEFORE the turn started: merge the fragments
|
|
11
|
+
# into one turn (the whole mechanism is a timer at the door).
|
|
12
|
+
# steer — the turn is ALREADY running tools: append the message to the run in
|
|
13
|
+
# flight, at a tool-batch boundary, so the customer's correction lands
|
|
14
|
+
# before the model's next step instead of after the whole run.
|
|
15
|
+
# interrupt — the turn is running and is now answering the wrong question: abandon
|
|
16
|
+
# it at its next boundary and let the new message be its own turn.
|
|
17
|
+
#
|
|
18
|
+
# They never compete for the same message: `collect` only ever touches a turn that
|
|
19
|
+
# has not started; `steer` and `interrupt` only a turn that has, and they differ in
|
|
20
|
+
# whether the run in flight is still worth finishing.
|
|
21
|
+
#
|
|
22
|
+
# Resolution per message, the order EdgeLimiter already documents
|
|
23
|
+
# (`edge_limiter.rb:17`) — configuration over convention:
|
|
24
|
+
#
|
|
25
|
+
# session vars["queue_mode"] — one conversation pinned by an operator
|
|
26
|
+
# profile.limits[:<key>] — per-agent (a PRESENT key wins, incl. nil/0 = off)
|
|
27
|
+
# settings["queue"][<key>] — platform default, editable in the Studio
|
|
28
|
+
# DEFAULTS[<key>] — = today's behavior
|
|
29
|
+
#
|
|
30
|
+
# Every default is off: a bare wiring behaves exactly as it did before this
|
|
31
|
+
# existed. The knobs live in `profile.limits` next to `tool_concurrency` and
|
|
32
|
+
# `turn_timeout` because they are bounds on the same thing — how much work one
|
|
33
|
+
# turn is allowed to absorb.
|
|
34
|
+
class QueuePolicy
|
|
35
|
+
# All four of are delivered, so there is no "specified but unshipped"
|
|
36
|
+
# tier any more — a mode outside this set is a typo, and it is refused rather than
|
|
37
|
+
# approximated. Treating an unknown mode as `followup` would look exactly like a
|
|
38
|
+
# mode that never fires.
|
|
39
|
+
MODES = %i[followup collect steer interrupt].freeze
|
|
40
|
+
|
|
41
|
+
DEFAULTS = {
|
|
42
|
+
queue_mode: :followup,
|
|
43
|
+
debounce_ms: 0, # 0 = no window: dequeue immediately, today's path
|
|
44
|
+
debounce_max_ms: 10_000, # ceiling on the TOTAL deferral (see #debounce_deadline_ms)
|
|
45
|
+
steer_max_messages: 5, # how many messages ONE run may absorb; overflow = followup
|
|
46
|
+
steer_join: nil # nil = the raw text; a template frames it (see #frame)
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# The placeholder `steer_join` must carry, so a template that would silently
|
|
50
|
+
# drop the customer's message is a config error and not a lost message.
|
|
51
|
+
JOIN_PLACEHOLDER = "%{message}"
|
|
52
|
+
|
|
53
|
+
attr_reader :mode, :debounce_ms, :debounce_max_ms, :steer_max_messages, :steer_join
|
|
54
|
+
|
|
55
|
+
# profile: an AgentProfile (or nil); settings_store: nil = no platform layer;
|
|
56
|
+
# vars: the session's vars Hash (string keys, as the SessionStore returns).
|
|
57
|
+
def self.resolve(profile, settings_store: nil, vars: nil)
|
|
58
|
+
platform = ((settings_store&.get || {})["queue"] || {})
|
|
59
|
+
limits = profile.respond_to?(:limits) ? (profile.limits || {}) : {}
|
|
60
|
+
|
|
61
|
+
new(
|
|
62
|
+
mode: mode!(pick_mode(vars, limits, platform)),
|
|
63
|
+
debounce_ms: pick(:debounce_ms, limits, platform),
|
|
64
|
+
debounce_max_ms: pick(:debounce_max_ms, limits, platform),
|
|
65
|
+
steer_max_messages: pick(:steer_max_messages, limits, platform),
|
|
66
|
+
steer_join: pick_text(:steer_join, limits, platform)
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize(mode:, debounce_ms:, debounce_max_ms:,
|
|
71
|
+
steer_max_messages: DEFAULTS[:steer_max_messages], steer_join: nil)
|
|
72
|
+
@mode = mode
|
|
73
|
+
@debounce_ms = [debounce_ms.to_i, 0].max
|
|
74
|
+
# A non-positive ceiling would mean "defer forever", which nobody wants and
|
|
75
|
+
# which a stray 0 in a config would silently buy. Fall back to the default.
|
|
76
|
+
max = debounce_max_ms.to_i
|
|
77
|
+
@debounce_max_ms = max.positive? ? max : DEFAULTS[:debounce_max_ms]
|
|
78
|
+
# 0 (or a negative) is a legitimate "never steer on this agent" — unlike the
|
|
79
|
+
# ceiling above, it forbids rather than defers forever, so it is honored.
|
|
80
|
+
@steer_max_messages = [steer_max_messages.to_i, 0].max
|
|
81
|
+
@steer_join = join!(steer_join)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Does this policy want messages held at the door at all?
|
|
85
|
+
def debounce? = @debounce_ms.positive?
|
|
86
|
+
|
|
87
|
+
# Does this policy merge into a turn that has not started yet?
|
|
88
|
+
def collect? = @mode == :collect
|
|
89
|
+
|
|
90
|
+
# Does this policy append into a turn that is already running? `steer_max_messages`
|
|
91
|
+
# of 0 is the agent saying no, so it answers false rather than steering once and
|
|
92
|
+
# then refusing.
|
|
93
|
+
def steer? = @mode == :steer && @steer_max_messages.positive?
|
|
94
|
+
|
|
95
|
+
# Does this policy abandon the turn in flight? The new message then becomes an
|
|
96
|
+
# ordinary turn of its own — which is why `interrupt`, unlike the two joining modes,
|
|
97
|
+
# needs no verdict field and works on every surface.
|
|
98
|
+
def interrupt? = @mode == :interrupt
|
|
99
|
+
|
|
100
|
+
# The content of the injected message. `steer_join` frames it when an agent needs
|
|
101
|
+
# the model to know this text arrived mid-run ("the customer just added: %{message}");
|
|
102
|
+
# nil — the default — appends exactly what the person typed. A plain gsub, not
|
|
103
|
+
# `format`: the text is a customer's, and a stray `%` in it must not raise.
|
|
104
|
+
def frame(text)
|
|
105
|
+
return text.to_s if @steer_join.nil?
|
|
106
|
+
|
|
107
|
+
@steer_join.gsub(JOIN_PLACEHOLDER, text.to_s)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A mode name -> Symbol, or raise. Blank = the default (an absent key is not
|
|
111
|
+
# an error; a WRONG key is).
|
|
112
|
+
def self.mode!(value)
|
|
113
|
+
return DEFAULTS[:queue_mode] if Coercion.blank?(value)
|
|
114
|
+
|
|
115
|
+
name = value.to_s.strip.downcase.to_sym
|
|
116
|
+
return name if MODES.include?(name)
|
|
117
|
+
|
|
118
|
+
raise Insika::ValidationError,
|
|
119
|
+
"unknown queue_mode: #{value.inspect} (expected #{MODES.join(', ')})"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A present key WINS even carrying nil/0 — "off for this agent", never
|
|
123
|
+
# "inherit the platform default". Same semantics EdgeLimiter documents at
|
|
124
|
+
# `edge_limiter.rb:57`, and for the same reason: an imported pack carrying an
|
|
125
|
+
# explicit null must not silently re-enable a platform behavior.
|
|
126
|
+
def self.pick(key, limits, platform)
|
|
127
|
+
return limits[key].to_i if limits.key?(key)
|
|
128
|
+
return platform[key.to_s].to_i if platform.key?(key.to_s)
|
|
129
|
+
|
|
130
|
+
DEFAULTS[key]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Same rule as #pick for a key whose value is TEXT: `to_i` would turn a template
|
|
134
|
+
# into 0. A present-but-nil key still means "off for this agent".
|
|
135
|
+
def self.pick_text(key, limits, platform)
|
|
136
|
+
return limits[key] if limits.key?(key)
|
|
137
|
+
return platform[key.to_s] if platform.key?(key.to_s)
|
|
138
|
+
|
|
139
|
+
DEFAULTS[key]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.pick_mode(vars, limits, platform)
|
|
143
|
+
from_vars = (vars || {})["queue_mode"] || (vars || {})[:queue_mode]
|
|
144
|
+
return from_vars if Coercion.present?(from_vars)
|
|
145
|
+
return limits[:queue_mode] if limits.key?(:queue_mode)
|
|
146
|
+
|
|
147
|
+
platform["queue_mode"]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private_class_method :pick, :pick_text, :pick_mode
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# A template that does not carry the placeholder would drop the customer's message
|
|
155
|
+
# while the turn still looked steered. Refused where the operator is (config time),
|
|
156
|
+
# not at 14:02 on a live conversation.
|
|
157
|
+
def join!(value)
|
|
158
|
+
return nil if Coercion.blank?(value)
|
|
159
|
+
|
|
160
|
+
text = value.to_s
|
|
161
|
+
return text if text.include?(JOIN_PLACEHOLDER)
|
|
162
|
+
|
|
163
|
+
raise Insika::ValidationError,
|
|
164
|
+
"steer_join must contain #{JOIN_PLACEHOLDER} (got #{value.inspect})"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|