insika 0.0.1 → 0.1.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 +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -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 +362 -0
- data/docs/SKILLS.md +98 -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 +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -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/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -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_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/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/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 +71 -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 +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -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 +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -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 +114 -0
- data/lib/insika/executor.rb +1680 -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/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 +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -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 +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -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/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 +87 -0
- data/lib/insika/safety/moderator.rb +86 -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/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -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 +113 -0
- data/lib/insika/skill_store.rb +79 -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 +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -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 +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -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/token_estimator.rb +16 -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_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 +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -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 +158 -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 +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- metadata +395 -8
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "coercion"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Single point of per-agent policy.
|
|
7
|
+
# ONE allowlist semantics for tools, skills, providers and workflows
|
|
8
|
+
# (nil = all [+ opt-in for optional tools]; [] = none for skills/allow;
|
|
9
|
+
# [names] = the final set; deny always wins) — a single rule, tested once.
|
|
10
|
+
#
|
|
11
|
+
# Deliberate EXCEPTION: `capabilities` does NOT follow the
|
|
12
|
+
# `nil = all` rule. nil/absent = NO capability (explicit opt-in — exposing
|
|
13
|
+
# every registered capability by mistake would couple the agent to plugins it
|
|
14
|
+
# didn't ask for). Do NOT "fix" it to be consistent with tools_allow.
|
|
15
|
+
AgentProfile = Data.define(
|
|
16
|
+
:id, :model, :provider,
|
|
17
|
+
:base_prompt, :prompt_files,
|
|
18
|
+
:tools_allow, :tools_deny,
|
|
19
|
+
:tools_allow_groups, # per-GROUP allowlist (Phase 7/D4/F5, Stage C):
|
|
20
|
+
# union with tools_allow; deny wins; both
|
|
21
|
+
# nil = all (parity). Expands to the group's
|
|
22
|
+
# tools in the ToolAllowlist policy.
|
|
23
|
+
:skills,
|
|
24
|
+
:context_providers, # provider allowlist
|
|
25
|
+
:workflows_allow, # applied by WorkflowAllowlist
|
|
26
|
+
:policies, # names in the Policy Registry
|
|
27
|
+
:prompt_refs, # names in the Prompt Catalog
|
|
28
|
+
:limits, # timeouts/budgets
|
|
29
|
+
:approvals_required, # tools that require approval (ApprovalRequired)
|
|
30
|
+
:capabilities, # intents the agent can trigger.
|
|
31
|
+
# nil = NONE (opt-in, see above).
|
|
32
|
+
:subagents, # allowlist of child agent ids this agent MAY spawn
|
|
33
|
+
# (RFC-0010, item 21). CAPACITY field — NEVER inherits;
|
|
34
|
+
# opt-in like `capabilities`: nil/absent = NONE (do NOT
|
|
35
|
+
# "fix" to nil = all). Present => the `spawn_subagent`
|
|
36
|
+
# system tool is wired (ChatBuilder), gated by this set.
|
|
37
|
+
:tools_deferred, # searchable-not-wired tools (Tool Search).
|
|
38
|
+
# nil = no deferred (all eager — parity);
|
|
39
|
+
# [names] ⊆ allowed_tools, exposed via tool_search.
|
|
40
|
+
:memory, # cross-session memory.
|
|
41
|
+
# nil/false = OFF (parity: provider []; the `remember`
|
|
42
|
+
# tool not wired); true = ON. Same opt-in as capabilities.
|
|
43
|
+
:prompt_caching, # Anthropic prompt caching (§11 R3): nil/false = OFF
|
|
44
|
+
# (parity); true = ON. Same opt-in as `memory`. When ON
|
|
45
|
+
# AND the resolved provider is Anthropic, ChatBuilder sets
|
|
46
|
+
# ONE cache breakpoint at the end of the system block
|
|
47
|
+
# (caches tools+system by the tools->system->messages
|
|
48
|
+
# prefix order; immune to history eviction). PRE-AUDIT:
|
|
49
|
+
# the system prompt MUST be byte-stable between turns —
|
|
50
|
+
# a context provider injecting volatile content into
|
|
51
|
+
# :system turns every turn into a paid cache WRITE with
|
|
52
|
+
# no read hit. Enable only for stable-system agents.
|
|
53
|
+
:params, # LLM generation params (v2, §10): a Hash with
|
|
54
|
+
# temperature/max_tokens/thinking, applied to the chat at
|
|
55
|
+
# stage 5. {} = provider defaults (parity).
|
|
56
|
+
:model_policy, # governance of WHICH models the agent may use (v2, §10):
|
|
57
|
+
# { "allow" => [refs] }. nil = NO fence (all models —
|
|
58
|
+
# parity). Enforced on the RESOLVED model (ModelResolver).
|
|
59
|
+
:guardrails, # content-safety config (RFC-0009 §3.3): { input:, output:,
|
|
60
|
+
# moderator:, strictness: }. OPT-IN like capabilities —
|
|
61
|
+
# nil/absent = the conservative default (Safety::Config:
|
|
62
|
+
# deterministic on, moderator off). Parsed, never a policy.
|
|
63
|
+
:sandbox, # confined-execution config (item 35, §4.6):
|
|
64
|
+
# { provider: "local"|"docker", root:, timeout:, ...+provider
|
|
65
|
+
# keys }. Declarative provider selection (config-over-code) —
|
|
66
|
+
# consumed by Insika::Sandbox.build. {} = absent (a
|
|
67
|
+
# deployment builds a `local` sandbox by default). It is
|
|
68
|
+
# CONFIG, never a policy — it does not decide security by
|
|
69
|
+
# itself; the FS boundary + approvals do.
|
|
70
|
+
:refinement, # self-improvement config (RFC-0013 §3.8):
|
|
71
|
+
# { mode: "report"|"propose"|"auto_apply", window: {…},
|
|
72
|
+
# files: [allowlist], proposers: [refs], budget: {tokens:},
|
|
73
|
+
# auto_apply_max_edits:, max_findings:, … }. nil/absent =
|
|
74
|
+
# REPORT-ONLY (phase A writes nothing to the agent, so
|
|
75
|
+
# reading your own traces needs no opt-in); `propose`
|
|
76
|
+
# and above must be enabled explicitly. It is CONFIG,
|
|
77
|
+
# never a policy — the write allowlist it carries is
|
|
78
|
+
# enforced by the applier, not by this field.
|
|
79
|
+
:capabilities_declared, # FACTS ABOUT THIS DEPLOYMENT that are not tools
|
|
80
|
+
# (RFC-0014 §3.5): %w[promotions human_handoff
|
|
81
|
+
# b2b_pricing]. An eval case declares what it
|
|
82
|
+
# `requires` and is SKIPPED — never failed — where
|
|
83
|
+
# the deployment lacks it, which is what makes one
|
|
84
|
+
# corpus usable across stores. A flat list the
|
|
85
|
+
# OPERATOR writes: inferring "this store has
|
|
86
|
+
# promotions" from data is how a suite starts lying.
|
|
87
|
+
# nil/[] = declares nothing.
|
|
88
|
+
# NOT `capabilities` above — that one is the
|
|
89
|
+
# capability-resolution intents the agent may
|
|
90
|
+
# trigger, a runtime allowlist. This one decides
|
|
91
|
+
# nothing at runtime and is read only by the evals.
|
|
92
|
+
:edge_stream, # WHICH INTERNAL CHANNELS MAY CROSS TO THE CUSTOMER:
|
|
93
|
+
# { "thinking" => bool, "intermediate" => bool }.
|
|
94
|
+
# {} / absent = NEITHER, which is the safe default and
|
|
95
|
+
# the reason the engine holds them back at all: the
|
|
96
|
+
# answer is `:content`, and a turn's other text (the
|
|
97
|
+
# provider's reasoning, the model narrating its tool
|
|
98
|
+
# loop) is for the Studio and the trace. A product that
|
|
99
|
+
# WANTS to show reasoning — a chat UI with a "thinking"
|
|
100
|
+
# panel — opts in per agent, and each channel then gets
|
|
101
|
+
# its OWN frame type at `/v1/responses`, never the
|
|
102
|
+
# answer's. Turning it on for a channel where the
|
|
103
|
+
# consumer concatenates every delta into one message
|
|
104
|
+
# (WhatsApp) puts the deliberation in front of a
|
|
105
|
+
# customer; that is the operator's call to make, not a
|
|
106
|
+
# default to inherit.
|
|
107
|
+
:metadata # free-form agent metadata, stable per agent
|
|
108
|
+
# (from the pack `agent.config.json`). Home of the `store_id`
|
|
109
|
+
# that becomes turn context (ctx.store_id, Phase 6/D2).
|
|
110
|
+
# It is NOT a policy — never decides security. {} = absent.
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Reopened class (not a Data.define block): a constant assigned inside
|
|
114
|
+
# the block would leak into the lexical scope (Insika::DEFAULT_LIMITS).
|
|
115
|
+
class AgentProfile
|
|
116
|
+
DEFAULT_LIMITS = {
|
|
117
|
+
turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
|
|
118
|
+
context_budget: 8_000, max_tool_calls: 50,
|
|
119
|
+
approval_timeout: 3_600, # cap on the wait for human approval (~1h)
|
|
120
|
+
# Item 30: parallel tool calls. ONE number is both the switch and the cap
|
|
121
|
+
# (nil/0/1 = serial, the default; N > 1 = at most N tool calls in flight).
|
|
122
|
+
# It sits next to tool_timeout/max_tool_calls because it is the third bound
|
|
123
|
+
# on tool execution. Read through TurnState#tool_concurrency, which also
|
|
124
|
+
# applies the approval gate (D3).
|
|
125
|
+
tool_concurrency: 1
|
|
126
|
+
}.freeze
|
|
127
|
+
|
|
128
|
+
# `model` is OPTIONAL as of v2 (§10): an agent without one resolves the
|
|
129
|
+
# platform `default_model` (Settings) at turn start via the ModelResolver.
|
|
130
|
+
def self.build(id:, model: nil, provider: nil, base_prompt: "", prompt_files: [],
|
|
131
|
+
tools_allow: nil, tools_deny: [], tools_allow_groups: nil, skills: nil,
|
|
132
|
+
context_providers: nil, workflows_allow: nil,
|
|
133
|
+
policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
|
|
134
|
+
capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
|
|
135
|
+
prompt_caching: nil,
|
|
136
|
+
params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
|
|
137
|
+
refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {})
|
|
138
|
+
new(
|
|
139
|
+
id: id, model: model, provider: provider, base_prompt: base_prompt,
|
|
140
|
+
prompt_files: Array(prompt_files), tools_allow: tools_allow,
|
|
141
|
+
tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
|
|
142
|
+
context_providers: context_providers, workflows_allow: workflows_allow,
|
|
143
|
+
policies: Array(policies), prompt_refs: Array(prompt_refs),
|
|
144
|
+
limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
|
|
145
|
+
capabilities: capabilities,
|
|
146
|
+
# opt-in like capabilities: nil => NONE. Array-normalize a present value so
|
|
147
|
+
# readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
|
|
148
|
+
subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
|
|
149
|
+
tools_deferred: tools_deferred, memory: memory,
|
|
150
|
+
prompt_caching: prompt_caching,
|
|
151
|
+
# The free-form hashes arrive with symbol keys (internal build) OR string
|
|
152
|
+
# keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
|
|
153
|
+
# here — the single front door every profile passes through — so no reader
|
|
154
|
+
# downstream has to defend against both (store_id, model_policy, Studio forms).
|
|
155
|
+
params: Coercion.deep_stringify(params || {}),
|
|
156
|
+
model_policy: Coercion.deep_stringify(model_policy),
|
|
157
|
+
guardrails: Coercion.deep_stringify(guardrails),
|
|
158
|
+
sandbox: Coercion.deep_stringify(sandbox),
|
|
159
|
+
refinement: Coercion.deep_stringify(refinement),
|
|
160
|
+
# Flat [String] — the evals compare it against a case's `requires`, and a
|
|
161
|
+
# symbol/string mix there would be a silent miss.
|
|
162
|
+
capabilities_declared: Array(capabilities_declared).map(&:to_s),
|
|
163
|
+
edge_stream: Coercion.deep_stringify(edge_stream || {}),
|
|
164
|
+
metadata: Coercion.deep_stringify(metadata || {})
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# opt-in for an optional tool = being in the agent's allow list.
|
|
169
|
+
def tool_opted_in?(name)
|
|
170
|
+
Array(tools_allow).include?(name)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# store_id of the turn context (ctx.store_id): lives in `metadata` (stable
|
|
174
|
+
# per store, comes from the pack). `build` string-keys metadata, so a plain
|
|
175
|
+
# string lookup is enough. nil = absent (the data-tool emits an empty header).
|
|
176
|
+
# It is NOT achei-specific: `store_id` is a field of the turn-context contract
|
|
177
|
+
# (§5), generic per project.
|
|
178
|
+
def store_id = (metadata || {})["store_id"]
|
|
179
|
+
|
|
180
|
+
# May this channel (:thinking / :intermediate) cross to the customer? Tolerant
|
|
181
|
+
# of the string values a form or a pack round-trip produces ("1"/"true"), and
|
|
182
|
+
# of anything else being absent: the safe reading is the default one.
|
|
183
|
+
def stream_public?(channel)
|
|
184
|
+
v = (edge_stream || {})[channel.to_s]
|
|
185
|
+
[true, "true", "1", "yes", "on"].include?(v)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Profile allowlist semantics, defined once: nil = all; [] = none;
|
|
5
|
+
# [names] = subset. It used to be copied in the Builder, the SkillCatalog and the
|
|
6
|
+
# policies — the rule must live in a single place.
|
|
7
|
+
module Allowlist
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Filters a collection by the allowlist, comparing `key.call(candidate)` (or the
|
|
11
|
+
# candidate itself) against the allowed names.
|
|
12
|
+
def filter(candidates, allow, &key)
|
|
13
|
+
return candidates if allow.nil?
|
|
14
|
+
return [] if allow.empty?
|
|
15
|
+
|
|
16
|
+
names = Array(allow).map(&:to_s)
|
|
17
|
+
candidates.select { |c| names.include?((key ? key.call(c) : c).to_s) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Per-item boolean variant (to compose with other filters in a select).
|
|
21
|
+
def allows?(allow, value)
|
|
22
|
+
return true if allow.nil?
|
|
23
|
+
return false if allow.empty?
|
|
24
|
+
|
|
25
|
+
Array(allow).map(&:to_s).include?(value.to_s)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# The ACCEPTED state of an agent's golden set (RFC-0008 §3.4, promoted to a store
|
|
7
|
+
# by RFC-0013 §3.7.3). One record per agent in the ConfigStore (scope "baselines"):
|
|
8
|
+
#
|
|
9
|
+
# { "at" => iso8601, "cases" => { "<case id>" => { "pass" => bool, "score" => n } } }
|
|
10
|
+
#
|
|
11
|
+
# Exactly the shape `Evals::Baseline.snapshot` already produces, so the file and
|
|
12
|
+
# the record are the same document in two places — no converter to keep honest,
|
|
13
|
+
# the same discipline `GoldenStore` applies to the corpus.
|
|
14
|
+
#
|
|
15
|
+
# **Why it had to leave the file.** `evals/baseline.json` works fine for the CLI,
|
|
16
|
+
# which runs from a checkout. The refinement gate does not: it runs inside a
|
|
17
|
+
# Railway deployment, scores a throwaway clone against the accepted state, and
|
|
18
|
+
# there is no checkout there to read. A gate that fell back to "no baseline found,
|
|
19
|
+
# nothing regressed" would be the most dangerous default in the system — so the
|
|
20
|
+
# baseline became a per-agent record, and the gate refuses when it is missing.
|
|
21
|
+
#
|
|
22
|
+
# The file stays the export format and the seed for a fresh deploy
|
|
23
|
+
# (`insika evals:baseline import|export`), for the same reason the goldens' YAML
|
|
24
|
+
# does. It is also still what `evals/run.rb --gate` reads for the pre-merge check,
|
|
25
|
+
# which is a checkout-side job and should stay one.
|
|
26
|
+
#
|
|
27
|
+
# Per AGENT and not one blob, unlike the file: a deployment serves many agents, a
|
|
28
|
+
# refinement run is about exactly one, and re-baselining the store after fixing
|
|
29
|
+
# agent A must not silently accept agent B's current state.
|
|
30
|
+
class BaselineStore
|
|
31
|
+
SCOPE = "baselines"
|
|
32
|
+
|
|
33
|
+
def initialize(config_store:)
|
|
34
|
+
@cs = config_store
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# -> { "at" =>, "cases" => {…} } | nil. nil means NOT RECORDED, which every
|
|
38
|
+
# caller must treat as "cannot gate", never as "nothing regressed".
|
|
39
|
+
def get(agent_id)
|
|
40
|
+
@cs.get(SCOPE, agent_id.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# -> the stored record. `snapshot` is `Evals::Baseline.snapshot` output (or the
|
|
44
|
+
# equivalent hash); `at` is stamped here when the snapshot carries none.
|
|
45
|
+
def put(agent_id, snapshot, at: nil)
|
|
46
|
+
raw = Coercion.deep_stringify(snapshot || {})
|
|
47
|
+
cases = raw["cases"]
|
|
48
|
+
raise Insika::ValidationError, "baseline needs a 'cases' mapping" unless cases.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
record = { "at" => Coercion.presence(raw["at"]) || at || timestamp, "cases" => cases }
|
|
51
|
+
@cs.put(SCOPE, agent_id.to_s, record)
|
|
52
|
+
record
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# -> bool (did it exist?). Removing a baseline disables the gate for that agent,
|
|
56
|
+
# which is the honest consequence and not a side effect worth hiding.
|
|
57
|
+
def delete(agent_id) = !!@cs.delete(SCOPE, agent_id.to_s)
|
|
58
|
+
|
|
59
|
+
# -> [String] agents with a recorded baseline.
|
|
60
|
+
def agents = @cs.keys(SCOPE)
|
|
61
|
+
|
|
62
|
+
# How many cases the accepted state covers. `0` and `nil` are different answers:
|
|
63
|
+
# nil = never recorded, 0 = recorded and empty (every case was skipped), and only
|
|
64
|
+
# the first is a configuration mistake.
|
|
65
|
+
def size(agent_id)
|
|
66
|
+
record = get(agent_id)
|
|
67
|
+
record && (record["cases"] || {}).size
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def timestamp = Time.now.utc.iso8601
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Capability
|
|
7
|
+
# Thin decorator: swaps only the `name` exposed to the model for the
|
|
8
|
+
# STABLE capability name (e.g. "browse"), regardless of which concrete impl
|
|
9
|
+
# (`impl_name`, e.g. "puppeteer_browser") resolution chose.
|
|
10
|
+
# `execute`/`parameters`/`description`/`call` keep delegating to the impl via
|
|
11
|
+
# SimpleDelegator — nothing reimplemented, same spirit as `ToolEnvelope`.
|
|
12
|
+
#
|
|
13
|
+
# Wrapping order in run_pipeline: impl -> ResolvedTool ->
|
|
14
|
+
# ToolEnvelope. The Envelope, from the outside, sees the already-renamed call (the
|
|
15
|
+
# model calls `browse`); for side_effect?/approval it needs the REAL `impl_name`
|
|
16
|
+
# (it's the tool_registry that knows whether "puppeteer_browser" is a side-effect, not
|
|
17
|
+
# "browse") — that's why `impl_name` is exposed here. This is consumed by the
|
|
18
|
+
# Executor (ToolEnvelope).
|
|
19
|
+
class ResolvedTool < SimpleDelegator
|
|
20
|
+
def initialize(impl, capability_name:, impl_name:)
|
|
21
|
+
super(impl)
|
|
22
|
+
@capability_name = capability_name.to_s
|
|
23
|
+
@impl_name = impl_name.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# STABLE name exposed to the model — shadows the impl's `name`.
|
|
27
|
+
def name = @capability_name
|
|
28
|
+
|
|
29
|
+
# Concrete name behind resolution — for side_effect?/approval in the
|
|
30
|
+
# ToolEnvelope, NEVER exposed to the model.
|
|
31
|
+
def impl_name = @impl_name
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Intent→implementation resolution. Pure INDIRECTION: does NOT inherit
|
|
5
|
+
# from `Registry` (which holds executables) — it holds `Provider`s (resolution
|
|
6
|
+
# metadata) and returns the `impl_name` that ANOTHER registry instantiates.
|
|
7
|
+
# Immutable post-boot by construction (only boot/loader registers), like `Registry`.
|
|
8
|
+
class CapabilityRegistry
|
|
9
|
+
Provider = Data.define(:capability, :impl_name, :kind, :plugin, :priority, :available)
|
|
10
|
+
# kind: :tool | :workflow
|
|
11
|
+
# priority: Integer | nil (nil = lowest; inherits plugin precedence)
|
|
12
|
+
# available: callable -> bool (default -> { true }; never nil in a registered Provider)
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
# capability(Symbol) -> [Provider], na ordem de registro (proxy de announce)
|
|
16
|
+
@providers = Hash.new { |h, k| h[k] = [] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Unlike `Registry#register`, there is NO "first wins": registering the
|
|
20
|
+
# same capability more than once is the normal case (providers competing) —
|
|
21
|
+
# dedup/tie-breaking happens in `resolve`, not here.
|
|
22
|
+
def register(capability, impl_name:, kind:, plugin: nil, priority: nil, available: nil)
|
|
23
|
+
unless %i[tool workflow].include?(kind)
|
|
24
|
+
raise ArgumentError, "invalid kind: #{kind.inspect} (use :tool or :workflow)"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if kind == :workflow
|
|
28
|
+
warn "[capability_registry] '#{capability}' registered with kind: :workflow — " \
|
|
29
|
+
"exposure to the agent deferred (L5)"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@providers[capability.to_sym] << Provider.new(
|
|
33
|
+
capability: capability.to_sym, impl_name: impl_name.to_s, kind: kind,
|
|
34
|
+
plugin: plugin&.to_s, priority: priority, available: available || -> { true }
|
|
35
|
+
)
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def providers(capability) = @providers[capability.to_sym].dup
|
|
40
|
+
|
|
41
|
+
def capabilities = @providers.keys
|
|
42
|
+
|
|
43
|
+
# Loader rollback, symmetric to `Registry#deregister_plugin`. Removes
|
|
44
|
+
# only the plugin's Providers; capabilities with no remaining provider drop
|
|
45
|
+
# from `capabilities` (clears the key so the Hash.new-with-block won't recreate it empty).
|
|
46
|
+
def deregister_plugin(plugin_id)
|
|
47
|
+
@providers.each_value { |list| list.delete_if { |p| p.plugin == plugin_id.to_s } }
|
|
48
|
+
@providers.delete_if { |_cap, list| list.empty? }
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# -> chosen Provider | raise CapabilityUnavailable | raise CapabilityAmbiguous.
|
|
53
|
+
# PURE and deterministic: same input → same choice or same error. No
|
|
54
|
+
# IO beyond the Provider's own `available.call`. Emits `:capability_resolved`
|
|
55
|
+
# when `event_stream:` is present (audit).
|
|
56
|
+
def resolve(capability, profile:, context: {}, event_stream: nil)
|
|
57
|
+
candidates = providers(capability)
|
|
58
|
+
candidates = candidates.select { |p| p.available.call }
|
|
59
|
+
candidates = apply_deny(candidates, profile)
|
|
60
|
+
|
|
61
|
+
raise CapabilityUnavailable.new(capability: capability) if candidates.empty?
|
|
62
|
+
|
|
63
|
+
chosen = pick_top(candidates, capability)
|
|
64
|
+
event_stream&.emit(Insika::Event.new(
|
|
65
|
+
type: :capability_resolved,
|
|
66
|
+
data: {
|
|
67
|
+
capability: capability.to_sym,
|
|
68
|
+
chosen: chosen.impl_name,
|
|
69
|
+
candidates: candidates.map do |p|
|
|
70
|
+
{ impl_name: p.impl_name, plugin: p.plugin, priority: p.priority }
|
|
71
|
+
end
|
|
72
|
+
}
|
|
73
|
+
))
|
|
74
|
+
chosen
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Resolution applies ONLY `tools_deny` over `impl_name` (deny ALWAYS wins) — it does
|
|
80
|
+
# NOT apply `tools_allow`: the grant to use the capability is
|
|
81
|
+
# listing it in `profile.capabilities`, checked by the Executor BEFORE
|
|
82
|
+
# calling `resolve`. Reusing `tools_allow` would filter out a provider for
|
|
83
|
+
# an agent that lists only the capability (not the raw impl). Per-agent provider
|
|
84
|
+
# pinning (`capability_providers`) is future work.
|
|
85
|
+
def apply_deny(candidates, profile)
|
|
86
|
+
deny = Array(profile.tools_deny).map(&:to_s)
|
|
87
|
+
candidates.reject { |p| deny.include?(p.impl_name) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `priority` desc primary, `nil` as the LOWEST possible (below
|
|
91
|
+
# any Integer, including negative — do not normalize to 0, which would collide
|
|
92
|
+
# with an explicit `priority: 0`). Tie-break by plugin precedence (registration
|
|
93
|
+
# order, announce proxy): different plugins always
|
|
94
|
+
# break the tie; same plugin (nil included) tied = CapabilityAmbiguous.
|
|
95
|
+
def pick_top(candidates, capability)
|
|
96
|
+
indexed = providers(capability).each_with_index.to_h { |p, i| [p, i] }
|
|
97
|
+
|
|
98
|
+
rank = ->(p) { p.priority.nil? ? [0, 0] : [1, p.priority] }
|
|
99
|
+
top_rank = candidates.map(&rank).max
|
|
100
|
+
top = candidates.select { |p| rank.call(p) == top_rank }
|
|
101
|
+
|
|
102
|
+
return top.first if top.size == 1
|
|
103
|
+
|
|
104
|
+
groups = top.group_by(&:plugin).values
|
|
105
|
+
if groups.any? { |g| g.size > 1 }
|
|
106
|
+
raise CapabilityAmbiguous.new(capability: capability, candidates: top)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
groups.min_by { |g| indexed[g.first] }.first
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Hands a finished turn's answer to a Shape B channel (RFC-0011 §6.5). The turn
|
|
7
|
+
# ended; the recipient is not on any connection; the reply has to travel out of
|
|
8
|
+
# band and survive a crash on the way. Three moves, in this order, and the order
|
|
9
|
+
# is the whole design:
|
|
10
|
+
#
|
|
11
|
+
# 1. RECORD at the turn's terminal (durable, `pending`).
|
|
12
|
+
# 2. CLAIM before the HTTP call (`pending -> delivering`, atomic). A crash
|
|
13
|
+
# between the claim and the POST loses that delivery; it does not duplicate
|
|
14
|
+
# it. At-most-once, stated rather than papered over — the same honest scope
|
|
15
|
+
# the async-delegation path already has.
|
|
16
|
+
# 3. RETRY, bounded and explicit. Unlike a delegation, the recipient is a third
|
|
17
|
+
# party with outages, so "keep trying" is a real requirement and "keep trying
|
|
18
|
+
# forever" is a real outage of ours.
|
|
19
|
+
#
|
|
20
|
+
# It is NOT a job queue: no scheduler, no priorities, no fan-out. The moment it
|
|
21
|
+
# grows one, the thing to do is take a real queue, not to finish building this.
|
|
22
|
+
class ChannelDelivery
|
|
23
|
+
MAX_ATTEMPTS = 3
|
|
24
|
+
# Waits BETWEEN attempts, so attempt 1 is immediate. Short on purpose: a
|
|
25
|
+
# customer waiting on WhatsApp is the deadline, not the consumer's SLA.
|
|
26
|
+
BACKOFF_SECONDS = [1, 5].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(channels:, outbox:, session_store:, event_stream: nil,
|
|
29
|
+
max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil)
|
|
30
|
+
@channels = channels
|
|
31
|
+
@outbox = outbox
|
|
32
|
+
@session_store = session_store
|
|
33
|
+
@event_stream = event_stream
|
|
34
|
+
@max_attempts = max_attempts
|
|
35
|
+
@backoff = Array(backoff)
|
|
36
|
+
@sleeper = sleeper || method(:default_sleep)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The turn committed an answer. -> the Delivery to dispatch, or nil when there
|
|
40
|
+
# is nothing to deliver, which is the common case and must stay cheap:
|
|
41
|
+
# · the turn did not come in through a channel,
|
|
42
|
+
# · the channel is Shape A (answers on its own stream — no `deliver`),
|
|
43
|
+
# · the answer is empty (a turn that died mid-message published nothing, and
|
|
44
|
+
# half a sentence was never an answer — P19),
|
|
45
|
+
# · or we do not know who to send it to.
|
|
46
|
+
def record(task:, channel_id:, content:)
|
|
47
|
+
return nil if content.to_s.strip.empty?
|
|
48
|
+
|
|
49
|
+
channel = @channels&.find(channel_id)
|
|
50
|
+
return nil unless channel.respond_to?(:deliver)
|
|
51
|
+
|
|
52
|
+
to = recipient(channel, task.session_id)
|
|
53
|
+
return nil if to.nil? || to.empty?
|
|
54
|
+
|
|
55
|
+
@outbox.create(
|
|
56
|
+
channel: channel_id, to: to, task_id: task.id, session_id: task.session_id,
|
|
57
|
+
payload: { "session_id" => task.session_id.to_s, "task_id" => task.id.to_s,
|
|
58
|
+
"content" => content.to_s }
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Claim + POST + bounded retry. Safe to call twice: the second caller loses the
|
|
63
|
+
# claim and returns without touching the recipient.
|
|
64
|
+
def deliver(id)
|
|
65
|
+
return false unless @outbox.claim(id)
|
|
66
|
+
|
|
67
|
+
delivery = @outbox.find(id)
|
|
68
|
+
channel = @channels&.find(delivery&.channel)
|
|
69
|
+
# The channel vanished between the record and the dispatch (a plugin was
|
|
70
|
+
# disabled, the deployment was reconfigured). Nothing can send this; leave it
|
|
71
|
+
# terminal so the boot sweep does not spin on it forever.
|
|
72
|
+
if channel.nil? || !channel.respond_to?(:deliver)
|
|
73
|
+
return finish(@outbox.mark_failed(id, error: "channel '#{delivery&.channel}' is not registered"))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
attempt(delivery, channel)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Boot: re-drive what a previous process recorded and never claimed. Records
|
|
80
|
+
# left `delivering` are deliberately NOT swept — that process may have POSTed
|
|
81
|
+
# before it died, and replaying is the duplicate the claim exists to prevent.
|
|
82
|
+
# -> { dispatched: [ids] }
|
|
83
|
+
def sweep
|
|
84
|
+
dispatched = @outbox.pending.map do |delivery|
|
|
85
|
+
deliver(delivery.id)
|
|
86
|
+
delivery.id
|
|
87
|
+
end
|
|
88
|
+
{ dispatched: dispatched }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def attempt(delivery, channel)
|
|
94
|
+
last_error = nil
|
|
95
|
+
|
|
96
|
+
@max_attempts.times do |i|
|
|
97
|
+
@sleeper.call(@backoff[i - 1]) if i.positive? && @backoff[i - 1]
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
status = channel.deliver(delivery.payload, to: delivery.to, delivery_id: delivery.id)
|
|
101
|
+
return finish(@outbox.mark_delivered(delivery.id)) if (200..299).cover?(status.to_i)
|
|
102
|
+
|
|
103
|
+
last_error = "HTTP #{status}"
|
|
104
|
+
rescue Insika::DeliveryError => e
|
|
105
|
+
last_error = e.message
|
|
106
|
+
end
|
|
107
|
+
@outbox.record_attempt(delivery.id, error: last_error)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
finish(@outbox.mark_failed(delivery.id, error: last_error))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The consumer's own key for this conversation. Written into the session's vars
|
|
114
|
+
# when the channel minted it (§4.3); the channel's own id parser is the fallback
|
|
115
|
+
# for a session created before those vars existed.
|
|
116
|
+
def recipient(channel, session_id)
|
|
117
|
+
session = session_id && @session_store.find(session_id)
|
|
118
|
+
vars = session&.vars || {}
|
|
119
|
+
from_vars = vars["external_id"] || vars[:external_id]
|
|
120
|
+
return from_vars.to_s if Insika::Coercion.presence(from_vars)
|
|
121
|
+
|
|
122
|
+
channel.respond_to?(:external_id_from) ? channel.external_id_from(session_id) : nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def finish(delivery)
|
|
126
|
+
emit(delivery)
|
|
127
|
+
delivery.status == :delivered
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def emit(delivery)
|
|
131
|
+
return unless @event_stream
|
|
132
|
+
|
|
133
|
+
@event_stream.emit(Insika::Event.new(
|
|
134
|
+
type: :channel_delivered,
|
|
135
|
+
data: { channel: delivery.channel, outbox_id: delivery.id,
|
|
136
|
+
status: delivery.status.to_s, attempts: delivery.attempts,
|
|
137
|
+
error: delivery.last_error },
|
|
138
|
+
meta: { task_id: delivery.task_id, session_id: delivery.session_id,
|
|
139
|
+
at: Time.now.utc.iso8601 }
|
|
140
|
+
))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Async when there is a reactor (production: the retry must not block the
|
|
144
|
+
# worker), plain sleep otherwise (boot sweep before the reactor, specs).
|
|
145
|
+
def default_sleep(seconds)
|
|
146
|
+
task = defined?(Async::Task) ? Async::Task.current? : nil
|
|
147
|
+
task ? task.sleep(seconds) : sleep(seconds)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# The channels this deployment speaks, by id (RFC-0011 §4.2). A `Registry` like
|
|
5
|
+
# tools and workflows — same plugin bookkeeping, so `deregister_plugin` rolls a
|
|
6
|
+
# half-registered plugin back exactly as it does for a tool.
|
|
7
|
+
#
|
|
8
|
+
# The id is the URL segment (`/channels/relay/events`), which is why lookup here
|
|
9
|
+
# is a `find` and not a `resolve`: an unknown segment is a 404, not an exception
|
|
10
|
+
# the transport has to rescue.
|
|
11
|
+
class ChannelRegistry < Registry
|
|
12
|
+
# -> the channel instance | nil (unknown id). A blank id never matches.
|
|
13
|
+
def find(id)
|
|
14
|
+
name = id.to_s
|
|
15
|
+
return nil if name.empty?
|
|
16
|
+
|
|
17
|
+
resolve(name)
|
|
18
|
+
rescue Insika::NotFoundError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Does this channel deliver out of band (Shape B)? A Shape A channel answers on
|
|
23
|
+
# the request's own stream and has no `deliver`, so nothing is ever written to
|
|
24
|
+
# the outbox for it. Duck-typed, like every other seam in the engine.
|
|
25
|
+
def deliverable?(id)
|
|
26
|
+
channel = find(id)
|
|
27
|
+
!channel.nil? && channel.respond_to?(:deliver)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|