insika 0.2.0 → 0.7.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 +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +17 -2
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +16 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +6 -3
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -27,13 +27,19 @@ module Insika
|
|
|
27
27
|
class OutputValidator
|
|
28
28
|
# `ask_factory` (optional): ->(config) { ->(prompt){text} | nil }, built by the
|
|
29
29
|
# Safety::Factory from the utility_model. nil = deterministic only.
|
|
30
|
-
|
|
30
|
+
# `grounding` (optional): a GroundingValidator — its step runs
|
|
31
|
+
# FIRST in #call, before the `config.output` gate (D9: grounding is
|
|
32
|
+
# evidence integrity, independent of the guardrails opt-in).
|
|
33
|
+
def initialize(ask_factory: nil, grounding: nil)
|
|
31
34
|
@ask_factory = ask_factory
|
|
35
|
+
@grounding = grounding
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
# after_task hook body. Idempotent and defensive: never raises out (a hook
|
|
35
|
-
# error must not fail a committed turn).
|
|
39
|
+
# error must not fail a committed turn). Grounding runs BEFORE the output
|
|
40
|
+
# gate so an agent with guardrails off and grounding on still gets the check.
|
|
36
41
|
def call(state)
|
|
42
|
+
state = @grounding&.call(state) || state
|
|
37
43
|
config = Config.from_profile(state.profile)
|
|
38
44
|
return state unless config.output
|
|
39
45
|
|
|
@@ -41,7 +47,7 @@ module Insika
|
|
|
41
47
|
return state if text.empty?
|
|
42
48
|
|
|
43
49
|
flags = []
|
|
44
|
-
flags.concat(deterministic_flags(text))
|
|
50
|
+
flags.concat(deterministic_flags(text, config.corpus))
|
|
45
51
|
flags.concat(llm_flags(text, config)) if config.moderator?
|
|
46
52
|
|
|
47
53
|
state.guardrail_flags = Array(state.guardrail_flags) + flags unless flags.empty?
|
|
@@ -55,8 +61,8 @@ module Insika
|
|
|
55
61
|
# Residual PII/secret that somehow reached the final text (the stream filter
|
|
56
62
|
# should have caught it — this is defense in depth, and the flag itself
|
|
57
63
|
# carries category counts, never the raw value).
|
|
58
|
-
def deterministic_flags(text)
|
|
59
|
-
_redacted, counts = Detectors.redact(text)
|
|
64
|
+
def deterministic_flags(text, corpus)
|
|
65
|
+
_redacted, counts = Detectors.redact(text, corpus: corpus)
|
|
60
66
|
return [] if counts.empty?
|
|
61
67
|
|
|
62
68
|
[{ category: "pii_residual", source: "deterministic", detail: counts.map { |k, v| "#{k}:#{v}" }.join(",") }]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# the parsed recurring-schedule declaration of ONE agent —
|
|
5
|
+
# the ONLY shape the engine accepts, shared by the ScheduleEngine, the
|
|
6
|
+
# doctor and the Studio. Pure value object (the followup-policy precedent).
|
|
7
|
+
#
|
|
8
|
+
# One schedule: a name, a trigger (`cron` OR `every`, never both), a
|
|
9
|
+
# timezone for cron materialization, the synthetic inbound message, a
|
|
10
|
+
# session mode (a fresh session per run, or a standing one), per-run
|
|
11
|
+
# overrides (turn_timeout / max_tool_calls / model) and an enabled flag.
|
|
12
|
+
#
|
|
13
|
+
# `parse` returns nil on a malformed hash (the engine SKIPS, the doctor
|
|
14
|
+
# explains, the Studio refuses); `parse!` raises Insika::ValidationError
|
|
15
|
+
# naming the exact defect.
|
|
16
|
+
class Schedule
|
|
17
|
+
SESSION_MODES = %w[new fixed].freeze
|
|
18
|
+
OVERRIDE_KEYS = %w[turn_timeout max_tool_calls model].freeze
|
|
19
|
+
ID_RE = /\A[a-z][a-z0-9_-]*\z/
|
|
20
|
+
|
|
21
|
+
attr_reader :id, :every, :cron, :tz, :message, :session_mode, :session_id,
|
|
22
|
+
:overrides, :enabled
|
|
23
|
+
|
|
24
|
+
def self.parse(hash)
|
|
25
|
+
new(hash)
|
|
26
|
+
rescue Insika::ValidationError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.parse!(hash)
|
|
31
|
+
new(hash)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(hash)
|
|
35
|
+
raise Insika::ValidationError, "schedule: declaration must be a Hash" unless hash.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
h = hash.transform_keys(&:to_s)
|
|
38
|
+
@id = id_of(h)
|
|
39
|
+
@every = every_of(h)
|
|
40
|
+
@cron = cron_of(h)
|
|
41
|
+
if @cron.nil? && @every.nil?
|
|
42
|
+
raise Insika::ValidationError,
|
|
43
|
+
"schedule '#{@id}': a trigger is required — declare cron or every"
|
|
44
|
+
end
|
|
45
|
+
@tz = tz_of(h)
|
|
46
|
+
@message = message_of(h)
|
|
47
|
+
@session_mode = session_mode_of(h)
|
|
48
|
+
@session_id = session_id_of(h)
|
|
49
|
+
@overrides = overrides_of(h)
|
|
50
|
+
@enabled = h.key?("enabled") ? h["enabled"] == true : true
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def cron? = !@cron.nil?
|
|
55
|
+
def every? = !@every.nil?
|
|
56
|
+
def fixed_session? = @session_mode == "fixed"
|
|
57
|
+
|
|
58
|
+
def to_h
|
|
59
|
+
{ "id" => @id, "cron" => @cron, "every" => @every, "tz" => @tz,
|
|
60
|
+
"message" => @message, "session_mode" => @session_mode,
|
|
61
|
+
"session_id" => @session_id, "overrides" => @overrides,
|
|
62
|
+
"enabled" => @enabled }.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def id_of(h)
|
|
68
|
+
id = h["id"].to_s.strip.downcase
|
|
69
|
+
unless ID_RE.match?(id)
|
|
70
|
+
raise Insika::ValidationError,
|
|
71
|
+
"schedule.id must match #{ID_RE.inspect}, got: #{h['id'].inspect}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
id
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def every_of(h)
|
|
78
|
+
every = h["every"]
|
|
79
|
+
return nil if every.nil?
|
|
80
|
+
|
|
81
|
+
unless every.is_a?(Integer) && every.positive?
|
|
82
|
+
raise Insika::ValidationError,
|
|
83
|
+
"schedule.every must be a positive integer of seconds, got: #{h['every'].inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
every
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def cron_of(h)
|
|
90
|
+
cron = h["cron"]
|
|
91
|
+
return nil if cron.nil?
|
|
92
|
+
|
|
93
|
+
if h.key?("every") && !h["every"].nil?
|
|
94
|
+
raise Insika::ValidationError,
|
|
95
|
+
"schedule '#{@id}': cron and every are mutually exclusive — declare exactly one trigger"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Insika::Cron.new(cron) # raises ValidationError on a malformed expression
|
|
99
|
+
cron.to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def tz_of(h)
|
|
103
|
+
tz = h["tz"].to_s
|
|
104
|
+
tz = "Etc/UTC" if tz.empty?
|
|
105
|
+
unless Insika::Timezone.known?(tz)
|
|
106
|
+
raise Insika::ValidationError, "schedule '#{@id}'.tz is not a valid IANA timezone: #{tz.inspect}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
tz
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def message_of(h)
|
|
113
|
+
message = h["message"]
|
|
114
|
+
if Coercion.blank?(message)
|
|
115
|
+
raise Insika::ValidationError,
|
|
116
|
+
"schedule '#{@id}'.message is required — the synthetic inbound that kicks each run"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
message.to_s
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def session_mode_of(h)
|
|
123
|
+
mode = h["session_mode"]
|
|
124
|
+
return "new" if mode.nil?
|
|
125
|
+
|
|
126
|
+
mode = mode.to_s
|
|
127
|
+
unless SESSION_MODES.include?(mode)
|
|
128
|
+
raise Insika::ValidationError,
|
|
129
|
+
"schedule '#{@id}'.session_mode must be one of #{SESSION_MODES.inspect}, got: #{h['session_mode'].inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
mode
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def session_id_of(h)
|
|
136
|
+
session_id = h["session_id"]
|
|
137
|
+
return nil if session_id.nil?
|
|
138
|
+
|
|
139
|
+
session_id.to_s
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def overrides_of(h)
|
|
143
|
+
overrides = h["overrides"]
|
|
144
|
+
return nil if overrides.nil?
|
|
145
|
+
|
|
146
|
+
raise Insika::ValidationError, "schedule '#{@id}'.overrides must be a Hash" unless overrides.is_a?(Hash)
|
|
147
|
+
|
|
148
|
+
overrides = overrides.transform_keys(&:to_s)
|
|
149
|
+
unknown = overrides.keys - OVERRIDE_KEYS
|
|
150
|
+
unless unknown.empty?
|
|
151
|
+
raise Insika::ValidationError,
|
|
152
|
+
"schedule '#{@id}'.overrides: unknown key(s) #{unknown.inspect} " \
|
|
153
|
+
"(allowed: #{OVERRIDE_KEYS.join(', ')})"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
overrides.each do |key, value|
|
|
157
|
+
# model is a provider/model REF — validated at declaration, not left
|
|
158
|
+
# for model-resolution time; everything else is an integer ceiling.
|
|
159
|
+
if key == "model"
|
|
160
|
+
if !value.is_a?(String) || value.strip.empty?
|
|
161
|
+
raise Insika::ValidationError,
|
|
162
|
+
"schedule '#{@id}'.overrides.model must be a non-blank String (a " \
|
|
163
|
+
"model ref), got: #{value.inspect}"
|
|
164
|
+
end
|
|
165
|
+
next
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
unless value.is_a?(Integer) && value.positive?
|
|
169
|
+
raise Insika::ValidationError,
|
|
170
|
+
"schedule '#{@id}'.overrides.#{key} must be a positive Integer, got: #{value.inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
overrides
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# the tick-driven FIRER of recurring schedules: the engine's
|
|
7
|
+
# fourth duty (after the outbox drain, retention/funnel and the follow-up
|
|
8
|
+
# firer). One pass per claim window (the followup/funnel idiom); each due
|
|
9
|
+
# schedule is claimed transactionally — re-read inside its own transaction,
|
|
10
|
+
# so two workers racing a window serialize on the backend lock and exactly
|
|
11
|
+
# one fires (the multi-worker at-most-once claim, per row).
|
|
12
|
+
#
|
|
13
|
+
# Gating order:
|
|
14
|
+
#
|
|
15
|
+
# declared? (reconciled from the profiles) -> enabled + due -> no-catch-up
|
|
16
|
+
# (a window older than one claim window is MISSED, recorded, never
|
|
17
|
+
# replayed) -> overlap (the last task still live) -> budget (a hard
|
|
18
|
+
# window at/over cap) -> FIRE.
|
|
19
|
+
#
|
|
20
|
+
# Skips are DATA, never silent: `last_skip { at, reason }` on the row, for
|
|
21
|
+
# the Studio. The engine never queues — a skipped window advances the
|
|
22
|
+
# schedule's lattice. The turn it creates is delivered by the existing
|
|
23
|
+
# pipeline (it holds no channel code, like the FollowupEngine).
|
|
24
|
+
class ScheduleEngine
|
|
25
|
+
SCOPE = "schedule_fire"
|
|
26
|
+
KEY = "claim"
|
|
27
|
+
DEFAULT_WINDOW = 300 # seconds; one firing worker per window
|
|
28
|
+
TENANT = "platform" # the single-tenant default (ledger rules) — see `tenant_for`
|
|
29
|
+
|
|
30
|
+
ACTIVE_STATUSES = %i[queued running waiting paused].freeze
|
|
31
|
+
|
|
32
|
+
# The tenant a SCHEDULED turn declares. Every other turn gets its tenant
|
|
33
|
+
# from its CALLER (an authenticated tenant token, a Command built with
|
|
34
|
+
# `tenant:`) — a scheduled turn has no caller, so it is the agent's OWN
|
|
35
|
+
# declaration instead: `metadata["tenant"]`, the same "stable per agent,
|
|
36
|
+
# from the pack" home `store_id` already lives in (never the model, never
|
|
37
|
+
# a policy — just a fact the pack states). Absent -> the single-tenant
|
|
38
|
+
# default, unchanged for every profile that does not declare one.
|
|
39
|
+
#
|
|
40
|
+
# PUBLIC and STATELESS on purpose: the Studio's own schedule list
|
|
41
|
+
# (`studio/app.rb`) must resolve to the exact SAME tenant the fire path
|
|
42
|
+
# uses, or a declared schedule becomes invisible there — one formula, two
|
|
43
|
+
# callers, never a second copy to drift.
|
|
44
|
+
def self.tenant_for(profile)
|
|
45
|
+
meta = profile.respond_to?(:metadata) ? profile.metadata : nil
|
|
46
|
+
Insika::Coercion.presence(meta && meta["tenant"]) || TENANT
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def initialize(store:, schedule_store:, task_store:, session_store:,
|
|
50
|
+
profiles:, executor:, budget_ledger: nil, event_stream: nil,
|
|
51
|
+
window: DEFAULT_WINDOW, now: nil)
|
|
52
|
+
@store = store
|
|
53
|
+
@schedule_store = schedule_store
|
|
54
|
+
@task_store = task_store
|
|
55
|
+
@session_store = session_store
|
|
56
|
+
@profiles = profiles
|
|
57
|
+
@executor = executor
|
|
58
|
+
@budget_ledger = budget_ledger
|
|
59
|
+
@event_stream = event_stream
|
|
60
|
+
@window = window
|
|
61
|
+
@now = now
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# -> { claimed: false }
|
|
65
|
+
# | { claimed: true, fired: N, skipped: N, errors: N,
|
|
66
|
+
# skip_reasons: { "reason" => N } }
|
|
67
|
+
# A StoreError on ONE schedule aborts THAT schedule's transaction
|
|
68
|
+
# (rescued, counted, the loop continues) — a broken row must not hold the
|
|
69
|
+
# other schedules' runs hostage.
|
|
70
|
+
def run
|
|
71
|
+
now_time = @now || Time.now.utc
|
|
72
|
+
return { claimed: false } unless claim_window(now_time)
|
|
73
|
+
|
|
74
|
+
sync_from_profiles(now_time)
|
|
75
|
+
|
|
76
|
+
fired = 0
|
|
77
|
+
skipped = 0
|
|
78
|
+
errors = 0
|
|
79
|
+
reasons = Hash.new(0)
|
|
80
|
+
|
|
81
|
+
@schedule_store.due(now: now_time).each do |record|
|
|
82
|
+
begin
|
|
83
|
+
outcome = fire_record(record, now_time)
|
|
84
|
+
case outcome
|
|
85
|
+
when :fired then fired += 1
|
|
86
|
+
when Array
|
|
87
|
+
# a skip is recorded on the row (never silent) and counted here.
|
|
88
|
+
skipped += 1
|
|
89
|
+
reasons[outcome[1].to_s] += 1
|
|
90
|
+
end
|
|
91
|
+
rescue StandardError
|
|
92
|
+
# a broken schedule must not hold the other schedules' runs
|
|
93
|
+
# hostage — its own transaction already rolled back.
|
|
94
|
+
errors += 1
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
{ claimed: true, fired: fired, skipped: skipped, errors: errors,
|
|
99
|
+
skip_reasons: reasons }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Reconciliation: the PROFILES are the source of the declarations (DSL /
|
|
105
|
+
# API / Studio); the store rows are the derived view the fire path reads.
|
|
106
|
+
# One pass: drop rows whose agent no longer exists, then upsert each
|
|
107
|
+
# profile's declared schedules (and drop each agent's undeclared rows).
|
|
108
|
+
def sync_from_profiles(now_time)
|
|
109
|
+
ids = @profiles.ids.map(&:to_s)
|
|
110
|
+
@store.transaction do
|
|
111
|
+
@schedule_store.all.each do |row|
|
|
112
|
+
@schedule_store.delete(tenant: row.tenant, agent: row.agent, id: row.id) unless ids.include?(row.agent)
|
|
113
|
+
end
|
|
114
|
+
@profiles.all.each do |profile|
|
|
115
|
+
schedules = profile.respond_to?(:schedules) ? profile.schedules : nil
|
|
116
|
+
@schedule_store.sync_declared(tenant: self.class.tenant_for(profile), agent: profile.id,
|
|
117
|
+
schedules: schedules, now: now_time)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# -> :fired | [:skipped, reason] — claimed per row, inside ONE
|
|
123
|
+
# transaction: the re-read, the gates, the task creation and the lattice
|
|
124
|
+
# advance commit together or not at all — and the SPAWN happens AFTER the
|
|
125
|
+
# commit, so a spawn failure never unwinds the fire (the durable :queued
|
|
126
|
+
# task is the recovery sweep's to handle). `next`, never `return`, inside
|
|
127
|
+
# the block (a non-local return skips the backend's COMMIT).
|
|
128
|
+
def fire_record(record, now_time)
|
|
129
|
+
task = nil
|
|
130
|
+
outcome = nil
|
|
131
|
+
spawn_profile = nil
|
|
132
|
+
@store.transaction do
|
|
133
|
+
current = @schedule_store.find(tenant: record.tenant, agent: record.agent, id: record.id)
|
|
134
|
+
# a racing worker already advanced/deleted the row — nothing for this pass.
|
|
135
|
+
unless current
|
|
136
|
+
outcome = [:skipped, :stale]
|
|
137
|
+
next
|
|
138
|
+
end
|
|
139
|
+
unless current.enabled
|
|
140
|
+
outcome = [:skipped, :stale]
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
next_at = Time.iso8601(current.next_fire_at.to_s)
|
|
144
|
+
unless next_at <= now_time
|
|
145
|
+
outcome = [:skipped, :stale] # the racing worker claimed the window
|
|
146
|
+
next
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# the no-catch-up policy: a window older than ONE claim window is
|
|
150
|
+
# MISSED, not replayed — the lattice advances and the row records it.
|
|
151
|
+
if next_at < now_time - @window
|
|
152
|
+
@schedule_store.mark_skip(id: current.id, tenant: current.tenant,
|
|
153
|
+
agent: current.agent, reason: :late,
|
|
154
|
+
next_fire_at: next_after(current, now_time),
|
|
155
|
+
now: now_time)
|
|
156
|
+
outcome = [:skipped, :late]
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# overlap: the previous run is still live — skip + record, never a queue.
|
|
161
|
+
if overlap?(current)
|
|
162
|
+
@schedule_store.mark_skip(id: current.id, tenant: current.tenant,
|
|
163
|
+
agent: current.agent, reason: :overlap,
|
|
164
|
+
next_fire_at: next_after(current, now_time),
|
|
165
|
+
now: now_time)
|
|
166
|
+
outcome = [:skipped, :overlap]
|
|
167
|
+
next
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
profile = @profiles.fetch(current.agent)
|
|
171
|
+
if profile && budget_exhausted?(profile, current, now_time)
|
|
172
|
+
@schedule_store.mark_skip(id: current.id, tenant: current.tenant,
|
|
173
|
+
agent: current.agent, reason: :budget,
|
|
174
|
+
next_fire_at: next_after(current, now_time),
|
|
175
|
+
now: now_time)
|
|
176
|
+
outcome = [:skipped, :budget]
|
|
177
|
+
next
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
task = commit_run(current, profile, now_time)
|
|
181
|
+
spawn_profile = derived_profile(current, profile)
|
|
182
|
+
outcome = :fired
|
|
183
|
+
end
|
|
184
|
+
return outcome || [:skipped, :stale] unless task
|
|
185
|
+
|
|
186
|
+
# AFTER the commit: the spawn. A failure propagates to the pass (counted
|
|
187
|
+
# as an error) — the fire already committed, and the :queued task is
|
|
188
|
+
# recovered by the tick's sweep.
|
|
189
|
+
@executor.spawn_in_session(task, profile: spawn_profile)
|
|
190
|
+
emit_fired(record, task.id)
|
|
191
|
+
:fired
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The atomic claim inside the pass's transaction: the task and the
|
|
195
|
+
# schedule's state (last run, task id, next fire) commit together or not
|
|
196
|
+
# at all (the follow-up firer's D5 shape).
|
|
197
|
+
def commit_run(current, profile, now_time)
|
|
198
|
+
session_id = resolve_session(current)
|
|
199
|
+
command = {
|
|
200
|
+
"type" => "scheduled_run",
|
|
201
|
+
"session_id" => session_id,
|
|
202
|
+
"payload" => {
|
|
203
|
+
"agent" => current.agent, "session_id" => session_id,
|
|
204
|
+
"message" => current.message, "origin" => Insika::MessageOrigin::SCHEDULED,
|
|
205
|
+
"schedule_id" => current.id
|
|
206
|
+
},
|
|
207
|
+
"meta" => { "tenant" => current.tenant, "transport" => "schedule" }
|
|
208
|
+
}
|
|
209
|
+
task = @task_store.create(command: command, session_id: session_id)
|
|
210
|
+
@schedule_store.transition_fire(id: current.id, tenant: current.tenant,
|
|
211
|
+
agent: current.agent, task_id: task.id,
|
|
212
|
+
next_fire_at: next_after(current, now_time),
|
|
213
|
+
now: now_time)
|
|
214
|
+
task
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# -> session_id for the run. session_mode "new" = a fresh session per run
|
|
218
|
+
# (the report case); "fixed" = the declared session, created on first run
|
|
219
|
+
# (the "standing assistant" case).
|
|
220
|
+
def resolve_session(current)
|
|
221
|
+
if current.session_mode == "fixed"
|
|
222
|
+
sid = current.session_id.to_s
|
|
223
|
+
sid = "sched-#{current.agent}-#{current.id}" if sid.empty?
|
|
224
|
+
@session_store.create(id: sid) unless @session_store.find(sid)
|
|
225
|
+
sid
|
|
226
|
+
else
|
|
227
|
+
@session_store.create.id
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# The profile the turn runs on: the base profile with the schedule's
|
|
232
|
+
# overrides merged (per-schedule ceiling, never a store-wide change). The
|
|
233
|
+
# base is untouched — a second schedule cannot see a sibling's overrides.
|
|
234
|
+
def derived_profile(current, base)
|
|
235
|
+
overrides = current.overrides
|
|
236
|
+
return base if overrides.nil? || overrides.empty?
|
|
237
|
+
|
|
238
|
+
limits = base.limits.dup
|
|
239
|
+
limits[:turn_timeout] = overrides["turn_timeout"] if overrides["turn_timeout"]
|
|
240
|
+
limits[:max_tool_calls] = overrides["max_tool_calls"] if overrides["max_tool_calls"]
|
|
241
|
+
Insika::AgentProfile.build(**base.to_h.merge(limits: limits,
|
|
242
|
+
model: overrides["model"] || base.model))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The next lattice point after `now`: for `every`, the next interval
|
|
246
|
+
# boundary; for cron, the next expression occurrence in the schedule's tz.
|
|
247
|
+
# nil (a cron that can never fire) makes the row never due again.
|
|
248
|
+
def next_after(current, now_time)
|
|
249
|
+
if current.every
|
|
250
|
+
base = Time.iso8601(current.next_fire_at.to_s)
|
|
251
|
+
base + ((now_time - base).to_i / current.every + 1) * current.every
|
|
252
|
+
else
|
|
253
|
+
Insika::Cron.new(current.cron).next_after(now_time, tz: current.tz)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def overlap?(current)
|
|
258
|
+
task_id = current.last_task_id.to_s
|
|
259
|
+
return false if task_id.empty?
|
|
260
|
+
|
|
261
|
+
task = @task_store.find(task_id)
|
|
262
|
+
task && ACTIVE_STATUSES.include?(task.status)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# A HARD budget at/over a window cap = skip (the edge would fail the turn
|
|
266
|
+
# anyway — this refuses to even queue it). A soft budget crosses and runs;
|
|
267
|
+
# the ledger warns as usual. Mirror of EdgeLimiter#budget_windows — the
|
|
268
|
+
# SOFT half is the edge's, this is the schedule gate's.
|
|
269
|
+
def budget_exhausted?(profile, current, now_time)
|
|
270
|
+
budget = profile.respond_to?(:budget) ? profile.budget : nil
|
|
271
|
+
return false if budget.nil? || @budget_ledger.nil?
|
|
272
|
+
|
|
273
|
+
soft = budget["soft"] == true
|
|
274
|
+
%i[daily monthly].any? do |window|
|
|
275
|
+
cap = budget[window.to_s].to_i
|
|
276
|
+
next false unless cap.positive?
|
|
277
|
+
|
|
278
|
+
spent = @budget_ledger.current(tenant: current.tenant, agent: current.agent,
|
|
279
|
+
now: now_time)[window]
|
|
280
|
+
!soft && spent >= cap
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def emit_fired(record, task_id)
|
|
285
|
+
return unless @event_stream
|
|
286
|
+
|
|
287
|
+
@event_stream.emit(Insika::Event.new(
|
|
288
|
+
type: :schedule_fired,
|
|
289
|
+
data: { id: record.id, agent: record.agent, task_id: task_id },
|
|
290
|
+
meta: { tenant: record.tenant, at: Time.now.utc.iso8601 }
|
|
291
|
+
))
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The claim window (the funnel_fold.rb idiom — read-check-write on one key
|
|
295
|
+
# inside a transaction): the O(n) scans never ride the 60 s tick, and two
|
|
296
|
+
# workers racing a pass serialize on the backend's lock.
|
|
297
|
+
def claim_window(now_time)
|
|
298
|
+
@store.transaction do
|
|
299
|
+
current = @store.get(SCOPE, KEY)
|
|
300
|
+
last = current && begin
|
|
301
|
+
Time.iso8601(current["claimed_at"].to_s)
|
|
302
|
+
rescue ArgumentError
|
|
303
|
+
nil # a corrupted claim is not a claim — take the window
|
|
304
|
+
end
|
|
305
|
+
if last.nil? || (now_time - last) >= @window
|
|
306
|
+
@store.set(SCOPE, KEY, { "claimed_at" => now_time.iso8601 })
|
|
307
|
+
true
|
|
308
|
+
else
|
|
309
|
+
false
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|