insika 0.1.0 → 0.3.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 +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -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 +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/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/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/frontmatter.rb +1 -1
- 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 +3 -3
- 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/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- 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 +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- 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 +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- data/lib/insika/studio/views/approvals.erb +4 -1
- 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 +4 -1
- 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 +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- 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 +0 -1
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
data/lib/insika/tick.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# The periodic tick: durability stops waiting for a reboot. One
|
|
7
|
+
# pass does two things, in this order:
|
|
8
|
+
#
|
|
9
|
+
# 1. DRAIN the outbox (`ChannelDelivery#sweep`) — replies a previous pass
|
|
10
|
+
# (or process) recorded and never claimed. Ungated: every record carries
|
|
11
|
+
# its own transactional claim, so N workers draining is safe.
|
|
12
|
+
# 2. SWEEP stale orphaned tasks (`Recovery#run(stale_after:)`) — gated by a
|
|
13
|
+
# bucketed claim (`Recovery.claim_sweep` on "tick:<epoch/interval>"), so
|
|
14
|
+
# exactly one worker per window sweeps. The staleness threshold is the
|
|
15
|
+
# liveness gate: a live :running turn is bounded by turn_timeout, so
|
|
16
|
+
# anything untouched past it cannot be alive.
|
|
17
|
+
#
|
|
18
|
+
# It is NOT a job queue: no schedules, no priorities, no fan-out. The
|
|
19
|
+
# refinement hook once pictured here is dropped by merit —
|
|
20
|
+
# docs/REFINEMENT.md's "no scheduler in the engine" stands.
|
|
21
|
+
class Tick
|
|
22
|
+
# 60s: a customer waiting on WhatsApp is the deadline. 900s = 3x the
|
|
23
|
+
# default turn_timeout (300s) — the rule, not the number: the threshold
|
|
24
|
+
# must exceed the deployment's largest turn_timeout, or the sweep would
|
|
25
|
+
# judge live turns orphaned.
|
|
26
|
+
DEFAULT_INTERVAL = 60
|
|
27
|
+
DEFAULT_STALE_AFTER = 900
|
|
28
|
+
|
|
29
|
+
SCOPE = "tick"
|
|
30
|
+
KEY = "claim"
|
|
31
|
+
|
|
32
|
+
def initialize(store:, recovery:, channel_delivery:, logger: nil,
|
|
33
|
+
interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
|
|
34
|
+
sleeper: nil, retention: nil, funnel: nil, followup: nil)
|
|
35
|
+
@store = store
|
|
36
|
+
@recovery = recovery
|
|
37
|
+
@channel_delivery = channel_delivery
|
|
38
|
+
@logger = logger
|
|
39
|
+
@interval = interval.to_i
|
|
40
|
+
@stale_after = stale_after.to_i
|
|
41
|
+
@sleeper = sleeper || method(:default_sleep)
|
|
42
|
+
@retention = retention # WS8: the daily age-based sweep; nil = none
|
|
43
|
+
@funnel = funnel # the tick-driven outcome fold; nil = none
|
|
44
|
+
@followup = followup # the tick-driven follow-up firer; nil = none
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# the fold is wired after the Tick is built (the graph passes
|
|
48
|
+
# it to `executor.tick.funnel =` — the outcome/funnel stores come from the
|
|
49
|
+
# spine). Setter + kwarg: same shape as `retention`.
|
|
50
|
+
attr_accessor :funnel
|
|
51
|
+
|
|
52
|
+
# the follow-up firer, wired after the Tick is built (same
|
|
53
|
+
# shape as `funnel` — the stores come from the spine).
|
|
54
|
+
attr_accessor :followup
|
|
55
|
+
|
|
56
|
+
def enabled? = @interval.positive?
|
|
57
|
+
|
|
58
|
+
# One pass, pure (no reactor needed): the serving loop calls it on a timer,
|
|
59
|
+
# specs call it directly. A StoreError propagates to the loop, which logs
|
|
60
|
+
# and keeps ticking.
|
|
61
|
+
def run_once
|
|
62
|
+
drained = @channel_delivery ? @channel_delivery.sweep : { dispatched: [] }
|
|
63
|
+
summary = { dispatched: drained[:dispatched], resumed: [], failed: [] }
|
|
64
|
+
# WS8 retention: cheap when not claimed (its own daily window) — the
|
|
65
|
+
# O(n) scans never ride the 60 s loop.
|
|
66
|
+
retention_summary = @retention&.run
|
|
67
|
+
summary[:retention] = retention_summary if retention_summary
|
|
68
|
+
# the outcome fold — one pass per claim window, cheap
|
|
69
|
+
# when another worker holds it. Sits next to retention, on the same tick.
|
|
70
|
+
funnel_summary = @funnel&.run
|
|
71
|
+
summary[:funnel] = funnel_summary if funnel_summary
|
|
72
|
+
# the follow-up firer — the tick's third duty, gated by its
|
|
73
|
+
# OWN claim window so the O(n) scans never ride the 60 s loop.
|
|
74
|
+
followup_summary = @followup&.run
|
|
75
|
+
summary[:followup] = followup_summary if followup_summary
|
|
76
|
+
return summary unless claim_window
|
|
77
|
+
|
|
78
|
+
result = @recovery.run(stale_after: @stale_after)
|
|
79
|
+
summary.merge(resumed: result[:resumed], failed: result[:failed])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The loop, spawned as a child of the turn supervisor (the
|
|
83
|
+
# tick lives on the supervisor fiber — every serving arm gets it the moment
|
|
84
|
+
# `supervised = true` matters, with no arm edits). A failing pass logs and
|
|
85
|
+
# the loop continues: a sweeper that dies silently is the outage it exists
|
|
86
|
+
# to prevent. Restartable: when the supervisor is recreated its children
|
|
87
|
+
# died with it, so a stopped task is not a running one.
|
|
88
|
+
def start(parent:)
|
|
89
|
+
return false unless enabled?
|
|
90
|
+
return true if @task&.running?
|
|
91
|
+
|
|
92
|
+
@task = parent.async do |t|
|
|
93
|
+
t.annotate("insika-tick")
|
|
94
|
+
loop do
|
|
95
|
+
@sleeper.call(@interval)
|
|
96
|
+
run_once
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
log(:warn, "tick failed: #{e.class}: #{e.message}")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# One sweeper per window across N workers. Unlike the boot
|
|
107
|
+
# claim (one key per generation), the tick reuses a SINGLE key with a
|
|
108
|
+
# timestamp — a key per minute would be a slow leak in the store. The
|
|
109
|
+
# read-check-write rides Store#transaction like every claim:
|
|
110
|
+
# two workers racing the window serialize on the backend's lock and exactly
|
|
111
|
+
# one sweeps.
|
|
112
|
+
def claim_window
|
|
113
|
+
now = Time.now.utc
|
|
114
|
+
@store.transaction do
|
|
115
|
+
current = @store.get(SCOPE, KEY)
|
|
116
|
+
last = current && begin
|
|
117
|
+
Time.iso8601(current["claimed_at"].to_s)
|
|
118
|
+
rescue ArgumentError
|
|
119
|
+
nil # a corrupted claim is not a claim — take the window
|
|
120
|
+
end
|
|
121
|
+
if last.nil? || (now - last) >= @interval
|
|
122
|
+
@store.set(SCOPE, KEY, { "claimed_at" => now.iso8601 })
|
|
123
|
+
true
|
|
124
|
+
else
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Async when there is a reactor (production: the interval must not block
|
|
131
|
+
# the worker), plain sleep otherwise (specs driving the loop by hand use an
|
|
132
|
+
# injected sleeper anyway).
|
|
133
|
+
def default_sleep(seconds)
|
|
134
|
+
task = defined?(Async::Task) ? Async::Task.current? : nil
|
|
135
|
+
task ? task.sleep(seconds) : sleep(seconds)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Same contract as Recovery's: logging is pure observability and must never
|
|
139
|
+
# alter the flow.
|
|
140
|
+
def log(level, message)
|
|
141
|
+
@logger&.public_send(level, "[tick] #{message}")
|
|
142
|
+
rescue StandardError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# Multi-tenant credentials (WS1): per-tenant tokens plus the operator token,
|
|
9
|
+
# stored ONLY as SHA-256 hashes — the plaintext is returned once at issue time
|
|
10
|
+
# and is never persisted, logged or evented. Every resolution is a hash lookup,
|
|
11
|
+
# so reading the store yields nothing usable. Behind any Insika::Store, like
|
|
12
|
+
# the other domain stores.
|
|
13
|
+
#
|
|
14
|
+
# A record describes ONE principal the edge can resolve a Bearer to:
|
|
15
|
+
# role: "operator" (tenant_id nil — the historical single credential)
|
|
16
|
+
# "tenant" (scoped to tenant_id)
|
|
17
|
+
# status: "active" | "revoked"
|
|
18
|
+
# A revoked record stops resolving immediately; a tenant's rotation is
|
|
19
|
+
# revoke-all + issue (the spec's rule — revoking one tenant's token never
|
|
20
|
+
# touches another tenant's: every cell is (id)- or (hash)-scoped cells).
|
|
21
|
+
class TokenStore
|
|
22
|
+
SCOPE = "tenant_tokens"
|
|
23
|
+
|
|
24
|
+
Record = Data.define(:id, :token_hash, :role, :tenant_id, :label,
|
|
25
|
+
:status, :created_at, :revoked_at) do
|
|
26
|
+
def active? = status.to_s == "active"
|
|
27
|
+
def tenant? = role.to_s == "tenant"
|
|
28
|
+
def to_h
|
|
29
|
+
{ id: id, role: role, tenant_id: tenant_id, label: label,
|
|
30
|
+
status: status, created_at: created_at, revoked_at: revoked_at }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The token a caller receives from a successful issue/rotate — the ONLY
|
|
35
|
+
# moment the plaintext exists in the process. `token` is never stored.
|
|
36
|
+
Issue = Data.define(:id, :token)
|
|
37
|
+
|
|
38
|
+
def initialize(store:)
|
|
39
|
+
@store = store
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Issues a token for tenant_id (nil = an OPERATOR token). -> Issue. The
|
|
43
|
+
# token is shown exactly once; there is no `get_token` — lost = rotate.
|
|
44
|
+
def issue(tenant_id: nil, label: "default")
|
|
45
|
+
validate_tenant_id!(tenant_id)
|
|
46
|
+
token = SecureRandom.hex(32)
|
|
47
|
+
hash = digest(token)
|
|
48
|
+
id = SecureRandom.uuid
|
|
49
|
+
@store.transaction do
|
|
50
|
+
@store.set(SCOPE, record_key(id), {
|
|
51
|
+
"id" => id, "token_hash" => hash,
|
|
52
|
+
"role" => tenant_id ? "tenant" : "operator",
|
|
53
|
+
"tenant_id" => tenant_id, "label" => label.to_s,
|
|
54
|
+
"status" => "active",
|
|
55
|
+
"created_at" => Time.now.utc.iso8601, "revoked_at" => nil
|
|
56
|
+
})
|
|
57
|
+
@store.set(SCOPE, hash_key(hash), id)
|
|
58
|
+
end
|
|
59
|
+
Issue.new(id: id, token: token)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Active-token record for token_id; a revoked one reads WHO it was but not
|
|
63
|
+
# as resolvable. -> Record | nil.
|
|
64
|
+
def find(id)
|
|
65
|
+
return nil if id.to_s.empty?
|
|
66
|
+
|
|
67
|
+
record = @store.get(SCOPE, record_key(id.to_s))
|
|
68
|
+
record && to_record(record)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The edge resolution: -> Record (active) | nil. A revoked token is
|
|
72
|
+
# indistinguishable from a missing one (fail-closed: the Bearer just 401s).
|
|
73
|
+
def resolve(token)
|
|
74
|
+
return nil if token.to_s.empty?
|
|
75
|
+
|
|
76
|
+
id = @store.get(SCOPE, hash_key(digest(token)))
|
|
77
|
+
return nil if id.nil?
|
|
78
|
+
|
|
79
|
+
record = to_record(@store.get(SCOPE, record_key(id)))
|
|
80
|
+
record&.active? ? record : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# -> bool: true only for an ACTIVE record (revoking an already-revoked/unknown
|
|
84
|
+
# id is false — a no-op, never an error). The read-modify-write rides
|
|
85
|
+
# `@store.transaction` (the repo's rule — WS1): `next false`, NOT `return
|
|
86
|
+
# false`, or the non-local return would skip the COMMIT and leak the open
|
|
87
|
+
# transaction (the same trap as the budget ledger's mark_alert).
|
|
88
|
+
def revoke(id)
|
|
89
|
+
@store.transaction do
|
|
90
|
+
record = find(id)
|
|
91
|
+
next false unless record&.active?
|
|
92
|
+
|
|
93
|
+
flipped = record.to_h.merge(status: "revoked", revoked_at: Time.now.utc.iso8601)
|
|
94
|
+
@store.set(SCOPE, record_key(id), stringify(flipped))
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Revokes every ACTIVE token of a tenant; the hash-index cells stay (they
|
|
100
|
+
# resolve to a revoked record -> nil). -> count of records revoked. Does NOT
|
|
101
|
+
# touch the operator token or any other tenant.
|
|
102
|
+
def revoke_all(tenant_id:)
|
|
103
|
+
ids = active_token_ids.select do |id|
|
|
104
|
+
record = to_record(@store.get(SCOPE, record_key(id)))
|
|
105
|
+
record&.tenant? && record.tenant_id.to_s == tenant_id.to_s
|
|
106
|
+
end
|
|
107
|
+
ids.count { |id| revoke(id) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Rotation: revoke the tenant's active tokens, issue a fresh one. Both in
|
|
111
|
+
# one transaction -> a crashed half-rotation never leaves the tenant with
|
|
112
|
+
# NOTHING valid. -> { revoked: n, issue: Issue }.
|
|
113
|
+
def rotate(tenant_id:, label: "default", now: Time.now)
|
|
114
|
+
@store.transaction do
|
|
115
|
+
revoked = revoke_all(tenant_id: tenant_id)
|
|
116
|
+
{ revoked: revoked, issue: issue(tenant_id: tenant_id, label: label) }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Every active token id (used by revoke_all). Reads the id-keyed cells.
|
|
121
|
+
def active_token_ids
|
|
122
|
+
@store.list(SCOPE, RECORD_PREFIX).filter_map do |key|
|
|
123
|
+
id = key.delete_prefix(RECORD_PREFIX)
|
|
124
|
+
record = @store.get(SCOPE, record_key(id))
|
|
125
|
+
record && record["status"] == "active" ? id : nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# The tenant id IS the session/task namespace prefix ("<tenant>:<id>" — WS1).
|
|
132
|
+
# A ":" lets a second tenant forge an id that lands on the FIRST tenant's
|
|
133
|
+
# namespace: T1="loja" + session id "adma:x" => "loja:adma:x", which is the
|
|
134
|
+
# cell T2="loja:adma" would claim with id "x" (WS1). Guarded at the single
|
|
135
|
+
# chokepoint both issue and rotate flow through.
|
|
136
|
+
def validate_tenant_id!(tenant_id)
|
|
137
|
+
return if tenant_id.nil?
|
|
138
|
+
|
|
139
|
+
raise Insika::ValidationError, "tenant_id is required" if Insika::Coercion.blank?(tenant_id)
|
|
140
|
+
raise Insika::ValidationError, "tenant_id must not contain ':'" if tenant_id.to_s.include?(":")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def digest(token)
|
|
144
|
+
Digest::SHA256.hexdigest(token)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
RECORD_PREFIX = "r:"
|
|
148
|
+
HASH_PREFIX = "h:"
|
|
149
|
+
|
|
150
|
+
def record_key(id) = "#{RECORD_PREFIX}#{id}"
|
|
151
|
+
def hash_key(hash) = "#{HASH_PREFIX}#{hash}"
|
|
152
|
+
|
|
153
|
+
def to_record(record)
|
|
154
|
+
return nil if record.nil?
|
|
155
|
+
|
|
156
|
+
Record.new(
|
|
157
|
+
id: record["id"], token_hash: record["token_hash"],
|
|
158
|
+
role: record["role"], tenant_id: record["tenant_id"],
|
|
159
|
+
label: record["label"], status: record["status"],
|
|
160
|
+
created_at: record["created_at"], revoked_at: record["revoked_at"]
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def stringify(hash)
|
|
165
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/insika/tool_assembly.rb
CHANGED
|
@@ -6,10 +6,10 @@ require "async/semaphore"
|
|
|
6
6
|
module Insika
|
|
7
7
|
# Turn-scoped assembly of the agent's tool instances (pipeline stage 3 tail):
|
|
8
8
|
# capability resolution, instantiation (Entry#factory | ready instance),
|
|
9
|
-
# turn-context
|
|
9
|
+
# turn-context injection, the capability<->direct dedup join, and the
|
|
10
10
|
# ToolEnvelope wrap (stage 7 seam: per-call timeout + side-effect recording).
|
|
11
11
|
#
|
|
12
|
-
# Extracted from the Executor
|
|
12
|
+
# Extracted from the Executor to keep the hot-path file smaller. It is
|
|
13
13
|
# a pure collaborator — it holds only the injected registries/stores and no
|
|
14
14
|
# per-turn state; everything turn-specific arrives via `state`/`turn_context`.
|
|
15
15
|
# The Executor keeps thin delegators (resolve_capabilities/assemble_tool_instances/
|
|
@@ -84,7 +84,7 @@ module Insika
|
|
|
84
84
|
|
|
85
85
|
private
|
|
86
86
|
|
|
87
|
-
#
|
|
87
|
+
# the model decides the fan-out, so without a cap a batch of 15
|
|
88
88
|
# data-tools is 15 simultaneous requests to the same upstream — which is how
|
|
89
89
|
# one turn earns a 429 for every other turn in the process. Note the contrast
|
|
90
90
|
# with the primitives that already shipped: `spawn_subagents` caps at 8
|
|
@@ -103,7 +103,7 @@ module Insika
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
# Real Engine -> Entries (respond to factory); fakes -> ready instances.
|
|
106
|
-
# `turn_context`
|
|
106
|
+
# `turn_context` is deposited into the instances that expose it
|
|
107
107
|
# (data-tools); the rest ignore it (parity).
|
|
108
108
|
def instantiate_tools(allowed, turn_context = nil)
|
|
109
109
|
Array(allowed).map do |t|
|
|
@@ -113,7 +113,7 @@ module Insika
|
|
|
113
113
|
end
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
#
|
|
116
|
+
# seam: deposits the turn context into the freshly created instance
|
|
117
117
|
# (same idea as `remember`, which receives tenant/state) BEFORE the
|
|
118
118
|
# ToolEnvelope. Duck-typed: only what exposes `turn_context=` (DataDefinedTool)
|
|
119
119
|
# receives it. nil (a state with no turn_context, e.g. a test stub) -> no-op.
|
|
@@ -7,8 +7,8 @@ module Insika
|
|
|
7
7
|
# Definition of a DATA-DEFINED TOOL (no Ruby code): name, description, parameters
|
|
8
8
|
# and an HTTP call. Immutable value object, persisted by ToolStore and
|
|
9
9
|
# materialized at runtime by Tools::DataDefinedTool (one class, N instances —
|
|
10
|
-
# the same pattern as A2ARemote)
|
|
11
|
-
# Schema in
|
|
10
|
+
# the same pattern as A2ARemote).,; parameters migrated to JSON
|
|
11
|
+
# Schema in,.
|
|
12
12
|
#
|
|
13
13
|
# Persisted form (JSON-serializable Hash; ConfigStore stringifies the keys):
|
|
14
14
|
# { "name", "description",
|
|
@@ -17,9 +17,9 @@ module Insika
|
|
|
17
17
|
# "response" => { "extract","path" },
|
|
18
18
|
# "secret_headers" => [ "Authorization", ... ],
|
|
19
19
|
# "side_effect" => bool, "timeout" => int|nil,
|
|
20
|
-
# "group" => string|nil, "tags" => [
|
|
20
|
+
# "group" => string|nil, "tags" => ["b2b",...] } #//
|
|
21
21
|
#
|
|
22
|
-
# `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP
|
|
22
|
+
# `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP):
|
|
23
23
|
# a nestable object, fed straight into RubyLLM's `params_schema` (provider-
|
|
24
24
|
# agnostic). The **flat array** (`[{name,type,required}]`) is SUGAR for the simple
|
|
25
25
|
# case: it is lifted to JSON Schema at build time. The sugar covers scalars and
|
|
@@ -30,11 +30,12 @@ module Insika
|
|
|
30
30
|
# Validation lives HERE (single source): `build`/`from_h` raise ValidationError
|
|
31
31
|
# on malformed input. Name uniqueness and collision with a code tool are NOT
|
|
32
32
|
# validated here (the value object does not know the registry) — that belongs to the
|
|
33
|
-
# overlay
|
|
33
|
+
# overlay. Secrets (credential headers) are the ToolStore's responsibility
|
|
34
34
|
# (masks/reconciles); the definition itself is agnostic to masking.
|
|
35
35
|
ToolDefinition = Data.define(
|
|
36
36
|
:name, :description, :parameters, :request, :response,
|
|
37
|
-
:secret_headers, :side_effect, :timeout, :group, :tags, :halt_when
|
|
37
|
+
:secret_headers, :side_effect, :timeout, :group, :tags, :halt_when,
|
|
38
|
+
:evidence # Insika::Evidence::Spec | nil
|
|
38
39
|
)
|
|
39
40
|
|
|
40
41
|
class ToolDefinition
|
|
@@ -46,17 +47,17 @@ module Insika
|
|
|
46
47
|
ARRAY_SUGAR = PARAM_TYPES.map { |t| "array:#{t}" }.freeze
|
|
47
48
|
HTTP_METHODS = %w[GET HEAD POST PUT PATCH DELETE].freeze
|
|
48
49
|
IDEMPOTENT = %w[GET HEAD].freeze # side_effect default = false
|
|
49
|
-
EXTRACTS = %w[body_raw status json_path].freeze
|
|
50
|
+
EXTRACTS = %w[body_raw status json_path evidence_envelope].freeze
|
|
50
51
|
NAME_RE = /\A[a-z][a-z0-9_]*\z/ # identifier for the model
|
|
51
52
|
# A `.` in the placeholder enables the turn-context namespace `{{ctx.*}}`
|
|
52
|
-
#
|
|
53
|
+
# separate from the model's `{{param}}`. Params follow NAME_RE (no
|
|
53
54
|
# dot) -> a placeholder with a dot can only be a ctx ref.
|
|
54
55
|
PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/
|
|
55
56
|
# Turn-context namespace: values coming from the TURN (not the model),
|
|
56
57
|
# resolved by DataDefinedTool. Closed allowlist (a typo becomes a validation
|
|
57
58
|
# error, not a silently empty header).
|
|
58
59
|
CTX_PREFIX = "ctx."
|
|
59
|
-
CTX_FIELDS = %w[chat_id store_id agent_id tenant].freeze
|
|
60
|
+
CTX_FIELDS = %w[chat_id store_id agent_id tenant image_url].freeze
|
|
60
61
|
|
|
61
62
|
# ---- safe subset of JSON Schema (R1) --------------------------------------
|
|
62
63
|
# Types supported by EVERY provider (OpenAI/Anthropic/Gemini/DeepSeek/Bedrock).
|
|
@@ -74,7 +75,7 @@ module Insika
|
|
|
74
75
|
# `parameters` accepts JSON Schema (Hash) OR the legacy flat array.
|
|
75
76
|
def self.build(name:, description:, request:, parameters: nil, response: nil,
|
|
76
77
|
secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
|
|
77
|
-
halt_when: nil)
|
|
78
|
+
halt_when: nil, evidence: nil)
|
|
78
79
|
name = name.to_s
|
|
79
80
|
raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)
|
|
80
81
|
|
|
@@ -84,6 +85,10 @@ module Insika
|
|
|
84
85
|
schema = normalize_params(parameters)
|
|
85
86
|
req = normalize_request(request, top_level_names(schema))
|
|
86
87
|
resp = normalize_response(response)
|
|
88
|
+
if resp[:extract] == "evidence_envelope" && evidence.nil?
|
|
89
|
+
raise Insika::ValidationError,
|
|
90
|
+
"extract 'evidence_envelope' requires an 'evidence' declaration"
|
|
91
|
+
end
|
|
87
92
|
|
|
88
93
|
method = req[:method]
|
|
89
94
|
effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)
|
|
@@ -93,7 +98,8 @@ module Insika
|
|
|
93
98
|
secret_headers: Array(secret_headers).map(&:to_s), side_effect: effect,
|
|
94
99
|
timeout: timeout.nil? ? nil : Integer(timeout),
|
|
95
100
|
group: normalize_group(group), tags: normalize_tags(tags),
|
|
96
|
-
halt_when: normalize_halt_when(halt_when)
|
|
101
|
+
halt_when: normalize_halt_when(halt_when),
|
|
102
|
+
evidence: Insika::Evidence::Spec.parse(evidence)
|
|
97
103
|
)
|
|
98
104
|
end
|
|
99
105
|
|
|
@@ -104,11 +110,11 @@ module Insika
|
|
|
104
110
|
name: h[:name], description: h[:description], parameters: h[:parameters],
|
|
105
111
|
request: h[:request] || {}, response: h[:response],
|
|
106
112
|
secret_headers: h[:secret_headers], side_effect: h[:side_effect], timeout: h[:timeout],
|
|
107
|
-
group: h[:group], tags: h[:tags], halt_when: h[:halt_when]
|
|
113
|
+
group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence]
|
|
108
114
|
)
|
|
109
115
|
end
|
|
110
116
|
|
|
111
|
-
# Group
|
|
117
|
+
# Group: enablement label by DATA (not name convention),
|
|
112
118
|
# target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
|
|
113
119
|
def self.normalize_group(group)
|
|
114
120
|
g = group.to_s.strip
|
|
@@ -145,7 +151,7 @@ module Insika
|
|
|
145
151
|
def self.empty_schema = { "type" => "object", "properties" => {}, "required" => [] }
|
|
146
152
|
private_class_method :empty_schema
|
|
147
153
|
|
|
148
|
-
# Flat sugar -> JSON Schema. Preserves
|
|
154
|
+
# Flat sugar -> JSON Schema. Preserves validation (NAME_RE, duplicates,
|
|
149
155
|
# PARAM_TYPES) and lifts `array:<scalar>` into proper `items`.
|
|
150
156
|
#
|
|
151
157
|
# A bare `array` is REFUSED. It used to lift to `items: {type:"string"}` — the
|
|
@@ -405,7 +411,7 @@ module Insika
|
|
|
405
411
|
# String-keyed Hash for persistence (ConfigStore stringifies again, but we
|
|
406
412
|
# normalize here so the record is stable across backends).
|
|
407
413
|
def to_h
|
|
408
|
-
{
|
|
414
|
+
h = {
|
|
409
415
|
"name" => name, "description" => description,
|
|
410
416
|
"parameters" => parameters,
|
|
411
417
|
"request" => request.transform_keys(&:to_s),
|
|
@@ -415,6 +421,10 @@ module Insika
|
|
|
415
421
|
"group" => group, "tags" => tags,
|
|
416
422
|
"halt_when" => halt_when&.transform_keys(&:to_s)
|
|
417
423
|
}
|
|
424
|
+
# present only when declared — a tool without evidence is byte-identical
|
|
425
|
+
# to today (no declaration, no envelope processing).
|
|
426
|
+
h["evidence"] = evidence.to_h if evidence
|
|
427
|
+
h
|
|
418
428
|
end
|
|
419
429
|
|
|
420
430
|
# -> true when this response ENDS the turn (no further model call). `body` is the
|
data/lib/insika/tool_envelope.rb
CHANGED
|
@@ -56,6 +56,11 @@ module Insika
|
|
|
56
56
|
|
|
57
57
|
started = monotonic
|
|
58
58
|
result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { __getobj__.call(args) } }
|
|
59
|
+
# the ONE seam every tool result passes on its way to the model.
|
|
60
|
+
# For a declared-evidence tool: reshape to the lean envelope, record the ids
|
|
61
|
+
# on the ledger, hoard the attachments. No evidence = the result passes
|
|
62
|
+
# through untouched (one nil-check — parity).
|
|
63
|
+
result = process_evidence(result)
|
|
59
64
|
record_side_effect!(call_id) if side_effect?
|
|
60
65
|
trace(call_id, args, result, started)
|
|
61
66
|
result
|
|
@@ -69,7 +74,7 @@ module Insika
|
|
|
69
74
|
|
|
70
75
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
71
76
|
|
|
72
|
-
#
|
|
77
|
+
# with parallel tool calls on, the turn's shared semaphore
|
|
73
78
|
# (TurnState#tool_gate, sized by `limits[:tool_concurrency]`) caps how many run
|
|
74
79
|
# at once. Wraps the REAL call ONLY — the approval wait and the skip check are
|
|
75
80
|
# outside it, so a call blocked on a human never holds a slot, and the per-call
|
|
@@ -136,5 +141,69 @@ module Insika
|
|
|
136
141
|
@checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
|
|
137
142
|
tool_call_id: call_id)
|
|
138
143
|
end
|
|
144
|
+
|
|
145
|
+
# ---- evidence ---------------------------------------------
|
|
146
|
+
|
|
147
|
+
# The evidence spec for the wrapped tool (D4). Resolution order:
|
|
148
|
+
# 1. the wrapped tool responds to `evidence` -> its spec (the data-tool
|
|
149
|
+
# path — DataDefinedTool exposes its definition's evidence);
|
|
150
|
+
# 2. otherwise the tool_registry entry's metadata carries an `evidence`
|
|
151
|
+
# spec (the code-tool path — a registry tool opts in at registration).
|
|
152
|
+
# No spec = pass the result through untouched (parity, byte-identical).
|
|
153
|
+
def evidence_spec
|
|
154
|
+
tool = __getobj__
|
|
155
|
+
if tool.respond_to?(:evidence)
|
|
156
|
+
raw = tool.evidence
|
|
157
|
+
return raw && Insika::Evidence::Spec.parse(raw)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
entry = @tool_registry.respond_to?(:entries) ? registry_entry(real_name) : nil
|
|
161
|
+
metadata = entry&.respond_to?(:metadata) ? entry.metadata : nil
|
|
162
|
+
raw = metadata && (metadata[:evidence] || metadata["evidence"])
|
|
163
|
+
raw && Insika::Evidence::Spec.parse(raw)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def registry_entry(name)
|
|
167
|
+
@tool_registry.entries.find { |e| e.name == name.to_s }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# -> result (possibly reshaped). NEVER raises out: a broken evidence result
|
|
171
|
+
# becomes the envelope error the model can act on, exactly like a malformed
|
|
172
|
+
# CALL is today. A tool ERROR result is never reshaped (an error must reach
|
|
173
|
+
# the model verbatim — the DataDefinedTool rule).
|
|
174
|
+
def process_evidence(result)
|
|
175
|
+
spec = evidence_spec
|
|
176
|
+
return result unless spec
|
|
177
|
+
return result if result.is_a?(Hash) && (result[:error] || result["error"])
|
|
178
|
+
|
|
179
|
+
raw = Insika::Evidence::Processor.raw(spec, result)
|
|
180
|
+
bad = Insika::SchemaGuard.violation_output(spec, raw)
|
|
181
|
+
return { error: bad } if bad
|
|
182
|
+
|
|
183
|
+
lean, attachments = Insika::Evidence::Processor.build(spec, raw)
|
|
184
|
+
record_evidence!(spec, lean)
|
|
185
|
+
hoard_attachments!(attachments)
|
|
186
|
+
lean
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
{ error: "evidence processing failed: #{e.message}" }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Ledger write + attachment hoarding, both via the state (duck-typed — the
|
|
192
|
+
# envelope's existing specs construct state stubs without these readers).
|
|
193
|
+
def record_evidence!(_spec, lean)
|
|
194
|
+
ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
|
|
195
|
+
return unless ledger
|
|
196
|
+
|
|
197
|
+
ids = Array(lean["items"]).map { |i| i["id"] }
|
|
198
|
+
ledger.record(ids) unless ids.empty?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def hoard_attachments!(attachments)
|
|
202
|
+
return if attachments.empty?
|
|
203
|
+
return unless @state.respond_to?(:evidence_attachments)
|
|
204
|
+
|
|
205
|
+
@state.evidence_attachments ||= []
|
|
206
|
+
@state.evidence_attachments.concat(attachments)
|
|
207
|
+
end
|
|
139
208
|
end
|
|
140
209
|
end
|
data/lib/insika/tool_manifest.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
# Tool manifest
|
|
4
|
+
# Tool manifest: the BATCH, DATA-driven, industry-STANDARD
|
|
5
5
|
# (JSON Schema) form of the data-tools. A `defaults` (common binding:
|
|
6
6
|
# base_url/path_template/method/headers/secret_headers/response) + a list of
|
|
7
7
|
# `tools`. Each tool is normalized — inheriting the defaults, applying the envelope
|
|
8
8
|
# adapter (OpenAI/MCP/raw) and resolving `endpoint`→url + `{{secret.*}}`/
|
|
9
9
|
# `{{env.*}}` — into a ToolDefinition Hash consumable by the ToolStore.
|
|
10
10
|
#
|
|
11
|
-
# GENERIC
|
|
11
|
+
# GENERIC: nothing here mentions a consumer/gateway — the manifest IS the contract. It
|
|
12
12
|
# only NORMALIZES/RESOLVES; the final validation (method/url/types, model placeholders
|
|
13
13
|
# `{{param}}`/turn `{{ctx.*}}`, safe subset of JSON Schema) belongs to
|
|
14
14
|
# ToolDefinition (single source). The batch upsert + hot reload belongs to the
|
|
@@ -18,12 +18,12 @@ module Insika
|
|
|
18
18
|
# - `{{param}}`/`{{ctx.*}}` : resolved AT THE TURN (by DataDefinedTool) —
|
|
19
19
|
# they stay INTACT here; ToolDefinition validates them.
|
|
20
20
|
# - `{{secret.*}}`/`{{env.*}}` : resolved AT INGESTION (here) — from the deployment,
|
|
21
|
-
# never from the manifest (
|
|
21
|
+
# never from the manifest (R3). `{{env.*}}` is non-secret config (any
|
|
22
22
|
# template); `{{secret.*}}` is a credential and is ONLY allowed in a header
|
|
23
23
|
# declared in `secret_headers`, with value === `{{secret.X}}` (nothing literal,
|
|
24
24
|
# nothing outside a header — otherwise the secret would leak without masking).
|
|
25
25
|
#
|
|
26
|
-
# Interface envelope (what the model sees), by adapter
|
|
26
|
+
# Interface envelope (what the model sees), by adapter:
|
|
27
27
|
# - `parameters` (raw JSON Schema), OR
|
|
28
28
|
# - `{ "function": { name, description, parameters } }` (OpenAI/Anthropic), OR
|
|
29
29
|
# - `{ "inputSchema": {...} }` (MCP).
|
|
@@ -73,17 +73,21 @@ module Insika
|
|
|
73
73
|
"response" => binding[:response],
|
|
74
74
|
"secret_headers" => binding[:secret_headers],
|
|
75
75
|
"side_effect" => t["side_effect"],
|
|
76
|
-
"group" => t["group"] || defaults["group"], #
|
|
76
|
+
"group" => t["group"] || defaults["group"], # per-group allowlist:
|
|
77
77
|
"tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
|
|
78
78
|
# Per-tool only: "this result ends the turn" is a property of THIS backend's
|
|
79
79
|
# response, never something a manifest default should hand to its siblings.
|
|
80
|
-
"halt_when" => t["halt_when"]
|
|
80
|
+
"halt_when" => t["halt_when"],
|
|
81
|
+
# the evidence declaration (lean envelope + ledger feed are the
|
|
82
|
+
# SAME data contract — D1). Normalized here; a malformed declaration is a
|
|
83
|
+
# ValidationError at ingestion, isolable per tool (R4).
|
|
84
|
+
"evidence" => t["evidence"] && Insika::Evidence::Spec.parse(t["evidence"]).to_h
|
|
81
85
|
}.compact
|
|
82
86
|
end
|
|
83
87
|
|
|
84
88
|
private
|
|
85
89
|
|
|
86
|
-
# Envelope adapter
|
|
90
|
+
# Envelope adapter: extracts the INTERFACE (name/description/parameters) from
|
|
87
91
|
# any standard form. name/description fall back to the tool's top level when the
|
|
88
92
|
# envelope does not carry them.
|
|
89
93
|
def extract_interface(t)
|