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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# the per-agent schedule rows of the recurring feature — one
|
|
8
|
+
# row per declared schedule, keyed `tenant:agent:id`, holding BOTH the
|
|
9
|
+
# declaration (a copy of the profile's `schedules` entry — the engine reads
|
|
10
|
+
# the store, never the profile, on the fire path) and the runtime state
|
|
11
|
+
# (`next_fire_at`, `last_run_at`, `last_task_id`, `last_skip`).
|
|
12
|
+
#
|
|
13
|
+
# The row is a DERIVED record: the profile declaration is the config (the
|
|
14
|
+
# DSL, the API, the Studio all edit the profile); the ScheduleEngine
|
|
15
|
+
# reconciles rows with declarations at each pass. The engine OWNS the
|
|
16
|
+
# runtime writes — `transition_fire`/`mark_skip` are called only inside the
|
|
17
|
+
# engine's transaction (the D5 discipline), never by a consumer. A row that
|
|
18
|
+
# is no longer declared is deleted (no zombie schedules); a row whose
|
|
19
|
+
# declaration is malformed is deleted too — a broken schedule must not keep
|
|
20
|
+
# firing with stale text (the doctor names it).
|
|
21
|
+
#
|
|
22
|
+
# Multi-worker at-most-once rides Store#transaction: the pass re-reads the
|
|
23
|
+
# row inside the transaction, so two workers serialize on the backend lock
|
|
24
|
+
# and only one advances `next_fire_at`.
|
|
25
|
+
class ScheduleStore
|
|
26
|
+
SCOPE = "schedules"
|
|
27
|
+
|
|
28
|
+
Record = Data.define(:id, :tenant, :agent, :every, :cron, :tz, :message,
|
|
29
|
+
:session_mode, :session_id, :overrides, :enabled,
|
|
30
|
+
:next_fire_at, :last_run_at, :last_task_id, :last_skip,
|
|
31
|
+
:created_at, :updated_at)
|
|
32
|
+
|
|
33
|
+
def initialize(store:)
|
|
34
|
+
@store = store
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Reconcile: upsert the declaration's row, or delete it when the
|
|
38
|
+
# declaration is gone/malformed. Called by the engine inside its pass
|
|
39
|
+
# transaction. A NEW or CHANGED declaration recomputes `next_fire_at`
|
|
40
|
+
# (the trigger lattice starts fresh); an unchanged row keeps its.
|
|
41
|
+
def sync_declared(tenant:, agent:, schedules: nil, now: Time.now.utc)
|
|
42
|
+
kept = []
|
|
43
|
+
Array(schedules).each do |declaration|
|
|
44
|
+
schedule = Insika::Schedule.parse(declaration)
|
|
45
|
+
next if schedule.nil? # malformed — dropped, the doctor names it
|
|
46
|
+
|
|
47
|
+
keep = upsert(tenant: tenant, agent: agent, schedule: schedule, now: now)
|
|
48
|
+
kept << schedule.id if keep
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# drop rows the agent no longer declares
|
|
52
|
+
prefix = "#{tenant_id(tenant)}:#{agent}:"
|
|
53
|
+
@store.list(SCOPE).each do |k|
|
|
54
|
+
next unless k.start_with?(prefix)
|
|
55
|
+
next if kept.include?(k.split(":").last)
|
|
56
|
+
|
|
57
|
+
@store.delete(SCOPE, k)
|
|
58
|
+
end
|
|
59
|
+
kept
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# -> Record | nil
|
|
63
|
+
def find(tenant:, agent:, id:)
|
|
64
|
+
record = @store.get(SCOPE, key(tenant, agent, id))
|
|
65
|
+
record && to_record(record)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# -> [Record] — one agent's rows (the Studio's read), lexicographic.
|
|
69
|
+
def for_agent(tenant:, agent:)
|
|
70
|
+
prefix = "#{tenant_id(tenant)}:#{agent}:"
|
|
71
|
+
@store.list(SCOPE).filter_map do |k|
|
|
72
|
+
next unless k.start_with?(prefix)
|
|
73
|
+
|
|
74
|
+
to_record(@store.get(SCOPE, k))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# -> [Record] — every row (the Studio grid, the engine pass).
|
|
79
|
+
def all
|
|
80
|
+
@store.list(SCOPE).filter_map { |k| to_record(@store.get(SCOPE, k)) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# -> [Record] — enabled AND due, oldest first (determinism). A row whose
|
|
84
|
+
# next_fire_at is nil (a cron that can never fire) is never due.
|
|
85
|
+
def due(now: Time.now.utc)
|
|
86
|
+
cutoff = now.iso8601
|
|
87
|
+
all.select { |r| r.enabled && !r.next_fire_at.to_s.empty? && r.next_fire_at.to_s <= cutoff }
|
|
88
|
+
.sort_by { |r| [r.next_fire_at.to_s, r.id] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The engine's atomic fire claim — the LAST step of the fire's
|
|
92
|
+
# transaction: advance the lattice, stamp the run, clear the skip.
|
|
93
|
+
# Callers pass `id` and the engine's OWN transaction encloses this.
|
|
94
|
+
def transition_fire(id:, tenant:, agent:, task_id:, next_fire_at:, now: Time.now.utc)
|
|
95
|
+
mutate(tenant, agent, id, now) do |record|
|
|
96
|
+
record["last_run_at"] = now.utc.iso8601
|
|
97
|
+
record["last_task_id"] = task_id.to_s
|
|
98
|
+
record["next_fire_at"] = next_fire_at.iso8601
|
|
99
|
+
record["last_skip"] = nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The visible skip — recorded, never silent: `next_fire_at` advances
|
|
104
|
+
# (the window is skipped, not queued) and `last_skip` names why.
|
|
105
|
+
def mark_skip(id:, tenant:, agent:, reason:, next_fire_at:, now: Time.now.utc)
|
|
106
|
+
mutate(tenant, agent, id, now) do |record|
|
|
107
|
+
record["last_skip"] = { "at" => now.utc.iso8601, "reason" => reason.to_s }
|
|
108
|
+
record["next_fire_at"] = next_fire_at.iso8601
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def delete(tenant:, agent:, id:)
|
|
113
|
+
@store.delete(SCOPE, key(tenant, agent, id))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# -> count removed. The LGPD sweep (a tenant's schedules die with it).
|
|
117
|
+
def purge(tenant:)
|
|
118
|
+
prefix = "#{tenant_id(tenant)}:"
|
|
119
|
+
keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
|
|
120
|
+
keys.each { |k| @store.delete(SCOPE, k) }
|
|
121
|
+
keys.size
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# Create or update ONE row from the declaration, recomputing next_fire_at
|
|
127
|
+
# on create and on any trigger/definition change. -> the schedule id.
|
|
128
|
+
def upsert(tenant:, agent:, schedule:, now:)
|
|
129
|
+
k = key(tenant, agent, schedule.id)
|
|
130
|
+
record = @store.get(SCOPE, k)
|
|
131
|
+
return schedule.id if record && !changed?(record, schedule)
|
|
132
|
+
|
|
133
|
+
next_fire_at = initial_next_fire(schedule, now)
|
|
134
|
+
row = {
|
|
135
|
+
"id" => schedule.id, "tenant" => tenant_id(tenant), "agent" => agent.to_s,
|
|
136
|
+
"every" => schedule.every, "cron" => schedule.cron, "tz" => schedule.tz,
|
|
137
|
+
"message" => schedule.message, "session_mode" => schedule.session_mode,
|
|
138
|
+
"session_id" => schedule.session_id, "overrides" => schedule.overrides,
|
|
139
|
+
"enabled" => schedule.enabled,
|
|
140
|
+
"next_fire_at" => next_fire_at&.iso8601,
|
|
141
|
+
"last_run_at" => record&.dig("last_run_at"),
|
|
142
|
+
"last_task_id" => record&.dig("last_task_id"),
|
|
143
|
+
"last_skip" => record&.dig("last_skip"),
|
|
144
|
+
"created_at" => record ? record["created_at"] : now.iso8601,
|
|
145
|
+
"updated_at" => now.iso8601
|
|
146
|
+
}
|
|
147
|
+
@store.set(SCOPE, k, row)
|
|
148
|
+
schedule.id
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Does the row's DECLARATION differ from the schedule? (runtime state is
|
|
152
|
+
# never part of the comparison.)
|
|
153
|
+
def changed?(record, schedule)
|
|
154
|
+
record["cron"] != schedule.cron || record["every"] != schedule.every ||
|
|
155
|
+
record["tz"] != schedule.tz || record["message"] != schedule.message ||
|
|
156
|
+
record["session_mode"] != schedule.session_mode ||
|
|
157
|
+
record["session_id"] != schedule.session_id ||
|
|
158
|
+
record["overrides"] != schedule.overrides ||
|
|
159
|
+
record["enabled"] != schedule.enabled
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The first fire after `now` for a schedule that has none yet: for `every`,
|
|
163
|
+
# one interval out; for cron, the next expression match. nil (a cron that
|
|
164
|
+
# can never fire) makes the row never due — `due` ignores a nil.
|
|
165
|
+
def initial_next_fire(schedule, now)
|
|
166
|
+
if schedule.every
|
|
167
|
+
now + schedule.every
|
|
168
|
+
else
|
|
169
|
+
Insika::Cron.new(schedule.cron).next_after(now, tz: schedule.tz)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def mutate(tenant, agent, id, now)
|
|
174
|
+
@store.transaction do
|
|
175
|
+
record = @store.get(SCOPE, key(tenant, agent, id))
|
|
176
|
+
raise Insika::NotFoundError, "schedule not found: #{tenant}:#{agent}:#{id}" if record.nil?
|
|
177
|
+
|
|
178
|
+
yield record
|
|
179
|
+
record["updated_at"] = now.utc.iso8601
|
|
180
|
+
@store.set(SCOPE, key(tenant, agent, id), record)
|
|
181
|
+
to_record(record)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def key(tenant, agent, id)
|
|
186
|
+
"#{tenant_id(tenant)}:#{agent.to_s}:#{id.to_s}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def tenant_id(tenant)
|
|
190
|
+
t = tenant.to_s
|
|
191
|
+
t.empty? ? "platform" : t
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def to_record(rec)
|
|
195
|
+
return nil if rec.nil?
|
|
196
|
+
|
|
197
|
+
Record.new(id: rec["id"], tenant: rec["tenant"], agent: rec["agent"],
|
|
198
|
+
every: rec["every"], cron: rec["cron"], tz: rec["tz"],
|
|
199
|
+
message: rec["message"], session_mode: rec["session_mode"],
|
|
200
|
+
session_id: rec["session_id"], overrides: rec["overrides"],
|
|
201
|
+
enabled: rec["enabled"] != false,
|
|
202
|
+
next_fire_at: rec["next_fire_at"],
|
|
203
|
+
last_run_at: rec["last_run_at"],
|
|
204
|
+
last_task_id: rec["last_task_id"], last_skip: rec["last_skip"],
|
|
205
|
+
created_at: rec["created_at"], updated_at: rec["updated_at"])
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
data/lib/insika/schema_guard.rb
CHANGED
|
@@ -131,6 +131,41 @@ module Insika
|
|
|
131
131
|
end
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
+
# Walks a dotted path on a plain object: nil when a segment
|
|
135
|
+
# is absent or the intermediate is not a Hash. Shared by the evidence output
|
|
136
|
+
# check and the Processor's extraction.
|
|
137
|
+
def dig(obj, path)
|
|
138
|
+
path.to_s.split(".").reduce(obj) do |cur, seg|
|
|
139
|
+
return nil unless cur.is_a?(Hash) && cur.key?(seg)
|
|
140
|
+
|
|
141
|
+
cur[seg]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The evidence RESULT contract: {items: [{id, line}]} .
|
|
146
|
+
# -> nil | String. Same idiom as `violation`: nil = fine, one message = what
|
|
147
|
+
# is wrong. A malformed evidence result is a correctable TOOL answer — the
|
|
148
|
+
# envelope returns it to the model as `{error:}`, exactly like a malformed
|
|
149
|
+
# call is today. A raw body that is NOT an object (a bare JSON array from a
|
|
150
|
+
# search, a string, nil) is a violation — never a silent `{items: []}` that
|
|
151
|
+
# the model reads as "no products".
|
|
152
|
+
def violation_output(spec, raw)
|
|
153
|
+
return nil if spec.nil?
|
|
154
|
+
return "evidence: result must be an object" unless raw.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
items = dig(raw, spec.items_path)
|
|
157
|
+
return "evidence: items is missing" if items.nil?
|
|
158
|
+
return "evidence: items must be a list" unless items.is_a?(Array)
|
|
159
|
+
|
|
160
|
+
items.each_with_index do |item, i|
|
|
161
|
+
ok = item.is_a?(Hash) &&
|
|
162
|
+
Coercion.present?(item["id"] || item[:id]) &&
|
|
163
|
+
(item["line"] || item[:line]).is_a?(String)
|
|
164
|
+
return "evidence: items[#{i}] must be {id, line}" unless ok
|
|
165
|
+
end
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
134
169
|
# Name the shape the way a model reads it, not the way Ruby does.
|
|
135
170
|
def kind(value)
|
|
136
171
|
case value
|
data/lib/insika/server/app.rb
CHANGED
|
@@ -55,7 +55,8 @@ module Insika
|
|
|
55
55
|
def initialize(command_bus:, event_stream:, session_store:, task_store:,
|
|
56
56
|
config:, pending_action_store: nil, a2a: nil, provisioner: nil,
|
|
57
57
|
workflow_registry: nil, onboarding: nil, profiles: nil,
|
|
58
|
-
channels: nil, logger: nil, token_store: nil
|
|
58
|
+
channels: nil, logger: nil, token_store: nil, outcome_store: nil,
|
|
59
|
+
executor: nil, db_path: nil, tool_registry: nil, mcp_store: nil)
|
|
59
60
|
@command_bus = command_bus
|
|
60
61
|
@event_stream = event_stream
|
|
61
62
|
@session_store = session_store
|
|
@@ -91,10 +92,29 @@ module Insika
|
|
|
91
92
|
# injecting the registry (nil ⇒ the routes do not exist, parity with @a2a).
|
|
92
93
|
# The channel does the translating; this class keeps doing only transport.
|
|
93
94
|
@channels = channels
|
|
95
|
+
# WS7: business outcomes per conversation (POST /v1/outcomes — a Control
|
|
96
|
+
# command on the bus — and the tenant-scoped GET /v1/outcomes read).
|
|
97
|
+
# nil = the routes 404 (parity).
|
|
98
|
+
@outcome_store = outcome_store
|
|
94
99
|
# where a 500's error_ref goes to be FOUND. nil = silent (parity for
|
|
95
100
|
# embedders); the serving wirings pass $stdout. Class+message+backtrace
|
|
96
101
|
# only — the ref never travels with request payloads (secrets stay out).
|
|
97
102
|
@logger = logger
|
|
103
|
+
# GET /v1/vitals: process readings, operator-only, no store.
|
|
104
|
+
# Both optional — nil means the body simply omits those readings.
|
|
105
|
+
@executor = executor
|
|
106
|
+
@db_path = db_path
|
|
107
|
+
# The deployment's EFFECTIVE tool registry, so the capability view can
|
|
108
|
+
# answer what the agent can reach that can WRITE (the derived
|
|
109
|
+
# eval-profile swap list). Read-only use (names/side_effect?) — same
|
|
110
|
+
# constitutional footing as the profile source. nil = the view omits it.
|
|
111
|
+
@tool_registry = tool_registry
|
|
112
|
+
# The durable MCP instance CRUD (GET/PUT/DELETE
|
|
113
|
+
# /v1/mcp[/:name]) — a direct store read/Command surface, same footing
|
|
114
|
+
# as @profiles/@outcome_store. nil = the routes 404 (parity — a
|
|
115
|
+
# deployment that never wires an McpStore never exposes MCP config
|
|
116
|
+
# over HTTP, same as one that skips provisioning).
|
|
117
|
+
@mcp_store = mcp_store
|
|
98
118
|
@heartbeat = config.fetch(:heartbeat, 15)
|
|
99
119
|
@sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
|
|
100
120
|
end
|
|
@@ -166,8 +186,20 @@ module Insika
|
|
|
166
186
|
handle_trigger_workflow(req, name)
|
|
167
187
|
in ["POST", ["v1", "responses"]]
|
|
168
188
|
handle_responses(req)
|
|
189
|
+
in ["POST", ["v1", "outcomes"]] if @outcome_store
|
|
190
|
+
handle_record_outcome(req)
|
|
191
|
+
in ["GET", ["v1", "outcomes"]] if @outcome_store
|
|
192
|
+
handle_list_outcomes(req)
|
|
169
193
|
in ["POST", ["v1", "tools", "manifest"]]
|
|
170
194
|
handle_import_tools(req)
|
|
195
|
+
in ["GET", ["v1", "mcp"]] if @mcp_store
|
|
196
|
+
handle_list_mcp
|
|
197
|
+
in ["GET", ["v1", "mcp", name]] if @mcp_store
|
|
198
|
+
handle_read_mcp(name)
|
|
199
|
+
in ["PUT", ["v1", "mcp"]] if @mcp_store
|
|
200
|
+
handle_upsert_mcp(req)
|
|
201
|
+
in ["DELETE", ["v1", "mcp", name]] if @mcp_store
|
|
202
|
+
handle_delete_mcp(req, name)
|
|
171
203
|
in ["POST", ["v1", "mcp", name, "import"]]
|
|
172
204
|
handle_import_mcp_tools(req, name)
|
|
173
205
|
in ["POST", ["v1", "agents"]] if @provisioner
|
|
@@ -182,8 +214,12 @@ module Insika
|
|
|
182
214
|
handle_read_task(req, id)
|
|
183
215
|
in ["GET", ["v1", "events"]]
|
|
184
216
|
handle_events(req)
|
|
217
|
+
in ["GET", ["v1", "vitals"]]
|
|
218
|
+
handle_vitals
|
|
185
219
|
in ["POST", ["channels", id, "events"]] if @channels
|
|
186
220
|
handle_channel_event(req, id)
|
|
221
|
+
in ["POST", ["channels", id, "shadow-reply"]] if @channels
|
|
222
|
+
handle_channel_shadow_reply(req, id)
|
|
187
223
|
in ["POST", ["channels", id, "sessions"]] if @channels
|
|
188
224
|
handle_channel_session(req, id)
|
|
189
225
|
in ["POST", ["channels", id, "messages"]] if @channels
|
|
@@ -241,7 +277,7 @@ module Insika
|
|
|
241
277
|
# tomorrow is gated by default and publishing it is a deliberate edit here.
|
|
242
278
|
def channel_route?(method, segments)
|
|
243
279
|
case [method, segments]
|
|
244
|
-
in ["POST", ["channels", _, "events" | "sessions" | "messages"]] then true
|
|
280
|
+
in ["POST", ["channels", _, "events" | "sessions" | "messages" | "shadow-reply"]] then true
|
|
245
281
|
in ["GET", ["channels", _, "asset", _]] then true
|
|
246
282
|
in ["OPTIONS", ["channels", _, *]] then true # CORS preflight carries no credential, by spec
|
|
247
283
|
else false
|
|
@@ -259,11 +295,44 @@ module Insika
|
|
|
259
295
|
# POST /v1/commands/:type — generic: every new Command is born with a
|
|
260
296
|
# transport. The control vs turn distinction is BY THE SHAPE of the result (the
|
|
261
297
|
# transport knows no semantics).
|
|
298
|
+
#
|
|
299
|
+
# The principal's tenant is stamped like on every other surface. It is
|
|
300
|
+
# nil for an operator (this route is operator-only — see TENANT_SURFACES),
|
|
301
|
+
# which is exactly why a tenant-scoped command such as `forget_customer`
|
|
302
|
+
# ALSO reads a `tenant` from its payload: over HTTP the operator names the
|
|
303
|
+
# tenant, because the credential does not carry one.
|
|
262
304
|
def handle_command(req, type)
|
|
263
|
-
command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http
|
|
305
|
+
command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http,
|
|
306
|
+
tenant: req_tenant(req))
|
|
264
307
|
command_response(dispatch_with_timeout(command))
|
|
265
308
|
end
|
|
266
309
|
|
|
310
|
+
# POST /v1/outcomes — the operator or the integration records a business
|
|
311
|
+
# outcome for a conversation (WS7). A Control command on the bus, tenant
|
|
312
|
+
# stamped from the principal (WS1) — additive, outside the response
|
|
313
|
+
# contract: the engine transports the outcome, never interprets it.
|
|
314
|
+
# 201 { outcome: { agent, outcome, value, session_id, at } }.
|
|
315
|
+
def handle_record_outcome(req)
|
|
316
|
+
command = Insika::Command.build(:record_outcome, parse_body(req), transport: :http,
|
|
317
|
+
tenant: req_tenant(req))
|
|
318
|
+
record = dispatch_with_timeout(command)
|
|
319
|
+
json_response(201, { outcome: { agent: record.agent, outcome: record.outcome,
|
|
320
|
+
value: record.value, session_id: record.session_id,
|
|
321
|
+
at: record.at } })
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# GET /v1/outcomes[?agent=] — the Studio scorecard's data: the LAST
|
|
325
|
+
# outcome per agent (state cards) + the per-day series. Tenant-scoped
|
|
326
|
+
# (WS1): a tenant principal reads only its own outcomes.
|
|
327
|
+
def handle_list_outcomes(req)
|
|
328
|
+
tenant = req_tenant(req)
|
|
329
|
+
agent = req.GET["agent"]
|
|
330
|
+
latest = @outcome_store.latest_per_agent(tenant: tenant)
|
|
331
|
+
latest = { agent => latest[agent] }.compact if agent && !agent.empty?
|
|
332
|
+
series = @outcome_store.series(tenant: tenant, agent: agent)
|
|
333
|
+
json_response(200, { latest: latest, series: series })
|
|
334
|
+
end
|
|
335
|
+
|
|
267
336
|
# POST /v1/sessions — sugar for create_session; 201 {session}.
|
|
268
337
|
def handle_create_session(req)
|
|
269
338
|
body = parse_body(req)
|
|
@@ -350,6 +419,10 @@ module Insika
|
|
|
350
419
|
ensure_session(parsed[:user], tenant: tenant)
|
|
351
420
|
payload = { agent: parsed[:agent], session_id: parsed[:user], message: parsed[:message] }
|
|
352
421
|
payload[:origin] = parsed[:origin] if parsed[:origin] # declared, else absent
|
|
422
|
+
payload[:customer] = parsed[:customer] if parsed[:customer] # WS8: the memory scope handle
|
|
423
|
+
payload[:parts] = parsed[:parts] if parsed[:parts] # WS9: multimodal content parts
|
|
424
|
+
payload[:source] = parsed[:source] if parsed[:source] # WS9: voice-marked text
|
|
425
|
+
payload[:channel] = parsed[:channel] if parsed[:channel] # WS9 (saída): the channel's output capabilities
|
|
353
426
|
message_flow(payload, stream: true, serialize: Responses.method(:frame_for),
|
|
354
427
|
tenant: tenant)
|
|
355
428
|
end
|
|
@@ -384,18 +457,61 @@ module Insika
|
|
|
384
457
|
json_response(200, dispatch_with_timeout(command))
|
|
385
458
|
end
|
|
386
459
|
|
|
387
|
-
#
|
|
460
|
+
# GET /v1/mcp — every configured MCP instance, MASKED (@mcp_store.all
|
|
461
|
+
# already masks — never plaintext over the wire). A direct store read,
|
|
462
|
+
# not a Command (constitutional rule at the top of this file); the
|
|
463
|
+
# generic route gate already confined this to the operator (not a
|
|
464
|
+
# TENANT_SURFACES entry), same footing as GET /v1/agents/:id.
|
|
465
|
+
def handle_list_mcp
|
|
466
|
+
json_response(200, { instances: @mcp_store.all })
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# GET /v1/mcp/:name — one instance, MASKED. Missing -> 404.
|
|
470
|
+
def handle_read_mcp(name)
|
|
471
|
+
record = @mcp_store.get(name)
|
|
472
|
+
raise Insika::NotFoundError, "MCP instance '#{name}' not found" if record.nil?
|
|
473
|
+
|
|
474
|
+
json_response(200, record)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# PUT /v1/mcp — create/edit an instance (the CLI/DSL/Studio
|
|
478
|
+
# config surfaces share this ONE seam: the `:upsert_mcp` Command already
|
|
479
|
+
# registered on the bus). Per-key secret reconciliation (the `__OCULTO__`
|
|
480
|
+
# sentinel round-trips: preserves; a new string replaces; "" clears) —
|
|
481
|
+
# same contract as `Insika::McpJson`. 200 { the MASKED upserted record }.
|
|
482
|
+
def handle_upsert_mcp(req)
|
|
483
|
+
gate = gateway_gate(req)
|
|
484
|
+
return gate if gate
|
|
485
|
+
|
|
486
|
+
command = Insika::Command.build(:upsert_mcp, parse_body(req), transport: :http)
|
|
487
|
+
json_response(200, dispatch_with_timeout(command))
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# DELETE /v1/mcp/:name — removes an instance (`:delete_mcp`). Idempotent
|
|
491
|
+
# (200 { existed: false } on a name that was never there — never a 404;
|
|
492
|
+
# deleting a thing that is already gone is not an error). The name comes
|
|
493
|
+
# from the ROUTE (data), not the body.
|
|
494
|
+
def handle_delete_mcp(req, name)
|
|
495
|
+
gate = gateway_gate(req)
|
|
496
|
+
return gate if gate
|
|
497
|
+
|
|
498
|
+
command = Insika::Command.build(:delete_mcp, { name: name }, transport: :http)
|
|
499
|
+
json_response(200, dispatch_with_timeout(command))
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# POST /v1/mcp/:name/import — kept as the alias for the
|
|
503
|
+
# "Refresh tools" action (the route predates the live registry; the name
|
|
504
|
+
# stuck). Dispatches :refresh_mcp_tools — LIVE connect + tools/list +
|
|
505
|
+
# write McpStore#tools_cache, never the retired ingestor snapshot. Same
|
|
388
506
|
# Bearer as provisioning (gateway_token, fail-closed): it's an authoring
|
|
389
|
-
# surface.
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
# + instance }. Missing instance -> 404; disabled/no-url -> 422; per-tool
|
|
393
|
-
# failure isolated in `errors[]` (R4). The name comes from the ROUTE (data), not the body.
|
|
507
|
+
# surface. Missing instance -> 404; disabled -> 422; a transport failure
|
|
508
|
+
# surfaces as whatever #call's rescue maps it to. The name comes from the
|
|
509
|
+
# ROUTE (data), not the body.
|
|
394
510
|
def handle_import_mcp_tools(req, name)
|
|
395
511
|
gate = gateway_gate(req)
|
|
396
512
|
return gate if gate
|
|
397
513
|
|
|
398
|
-
command = Insika::Command.build(:
|
|
514
|
+
command = Insika::Command.build(:refresh_mcp_tools, { name: name }, transport: :http)
|
|
399
515
|
json_response(200, dispatch_with_timeout(command))
|
|
400
516
|
end
|
|
401
517
|
|
|
@@ -412,22 +528,50 @@ module Insika
|
|
|
412
528
|
# (a client: it never reads a store) can tell "this case cannot run here" from
|
|
413
529
|
# "this case failed". Deliberately NOT the profile: the prompt,
|
|
414
530
|
# the model and the guardrail config are none of the caller's business. Just the
|
|
415
|
-
#
|
|
531
|
+
# three facts a case declares `requires` against — plus the side-effect set,
|
|
532
|
+
# which is what an eval profile must swap.
|
|
416
533
|
#
|
|
417
534
|
# `tools` is the DECLARED allowlist, and `null` means the agent has an open one
|
|
418
535
|
# (every registered tool) — the client reads that as "cannot rule anything out"
|
|
419
536
|
# and runs the case rather than skipping it.
|
|
537
|
+
#
|
|
538
|
+
# `side_effect_tools` is DERIVED here from the deployment's own registry (the
|
|
539
|
+
# effective one the Executor uses): the agent's reachable tools the registry
|
|
540
|
+
# marks `side_effect` (a data-tool's non-GET method, the MCP ingestor's
|
|
541
|
+
# `tools/call`). It is the list a simulated run's eval profile must cover —
|
|
542
|
+
# and it is a fact of the deployment, never a hand-maintained client list.
|
|
543
|
+
# Absent (no registry wired) -> the key is omitted, and the view behaves as
|
|
544
|
+
# before.
|
|
420
545
|
def handle_read_agent(id)
|
|
421
546
|
profile = @profiles.fetch(id)
|
|
422
547
|
raise Insika::NotFoundError, "agent not found: #{id}" if profile.nil?
|
|
423
548
|
|
|
424
549
|
allow = profile.tools_allow
|
|
425
550
|
deny = Array(profile.tools_deny).map(&:to_s)
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
551
|
+
payload = {
|
|
552
|
+
id: profile.id,
|
|
553
|
+
# `tools: null` is MEANINGFUL (an open allowlist — the client reads it
|
|
554
|
+
# as "cannot rule anything out"), so it must survive, unlike the new
|
|
555
|
+
# field below.
|
|
556
|
+
tools: allow.nil? ? nil : (Array(allow).map(&:to_s) - deny),
|
|
557
|
+
capabilities: Array(profile.capabilities_declared).map(&:to_s)
|
|
558
|
+
}
|
|
559
|
+
derived = side_effect_tools(profile)
|
|
560
|
+
payload[:side_effect_tools] = derived unless derived.nil?
|
|
561
|
+
json_response(200, payload)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# The derived side-effect set for an agent: its reachable tools (the same
|
|
565
|
+
# allowlist − deny resolution the `tools` field uses) that the registry marks
|
|
566
|
+
# `side_effect`, sorted for a stable answer. -> [names] | nil (no registry).
|
|
567
|
+
def side_effect_tools(profile)
|
|
568
|
+
return nil unless @tool_registry
|
|
569
|
+
|
|
570
|
+
allowed = if profile.tools_allow.nil? then Array(@tool_registry.names)
|
|
571
|
+
else Array(profile.tools_allow).map(&:to_s)
|
|
572
|
+
end
|
|
573
|
+
denied = Array(profile.tools_deny).map(&:to_s)
|
|
574
|
+
(allowed - denied).select { |name| @tool_registry.side_effect?(name) }.sort
|
|
431
575
|
end
|
|
432
576
|
|
|
433
577
|
# `/v1` only — `/a2a` is versioned by its own JSON-RPC spec and a channel's
|
|
@@ -481,6 +625,8 @@ TENANT_SURFACES = [
|
|
|
481
625
|
["POST", ["v1", "sessions"]],
|
|
482
626
|
["POST", ["v1", "messages"]],
|
|
483
627
|
["POST", ["v1", "responses"]],
|
|
628
|
+
["POST", ["v1", "outcomes"]],
|
|
629
|
+
["GET", ["v1", "outcomes"]],
|
|
484
630
|
["POST", ["v1", "workflows", nil]],
|
|
485
631
|
["GET", ["v1", "workflows"]],
|
|
486
632
|
["GET", ["v1", "sessions", nil]],
|
|
@@ -535,7 +681,52 @@ TENANT_SURFACES = [
|
|
|
535
681
|
|
|
536
682
|
payload = { agent: parsed[:agent], session_id: session_id,
|
|
537
683
|
message: parsed[:message], event_id: parsed[:event_id] }.compact
|
|
538
|
-
channel_ack(payload, transport: :"channel:#{id}")
|
|
684
|
+
ack = channel_ack(payload, transport: :"channel:#{id}")
|
|
685
|
+
# in shadow the mirror's own reply may ride the SAME call
|
|
686
|
+
# (Shape 1) — recorded before we answer, through the one command.
|
|
687
|
+
if channel.respond_to?(:shadow?) && channel.shadow?
|
|
688
|
+
dispatch_shadow_reply(id, parsed) if parsed[:incumbent_reply]
|
|
689
|
+
return shadow_ack(ack)
|
|
690
|
+
end
|
|
691
|
+
ack
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# POST /channels/:id/shadow-reply — the mirror contract's fallback (Shape 2),
|
|
695
|
+
# for a consumer that mirrors the exchange BEFORE it answers the customer.
|
|
696
|
+
# Same channel_gate (auth is the channel's, not the route's); 404 unless the
|
|
697
|
+
# channel is in shadow — the same parity every other optional surface has.
|
|
698
|
+
def handle_channel_shadow_reply(req, id)
|
|
699
|
+
channel = @channels.find(id)
|
|
700
|
+
return not_found if channel.nil? || !(channel.respond_to?(:shadow?) && channel.shadow?)
|
|
701
|
+
|
|
702
|
+
gate = channel_gate(channel, req)
|
|
703
|
+
return gate if gate
|
|
704
|
+
|
|
705
|
+
parsed = channel.parse_shadow_reply(req, body: parse_raw_body(req)) # ValidationError -> 422
|
|
706
|
+
result = dispatch_shadow_reply(id, parsed)
|
|
707
|
+
json_response(202, { pair_id: result[:pair_id], status: result[:status] })
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# The incumbent half rides ONE command whatever shape it arrived in, so
|
|
711
|
+
# server/ never writes to a store and both doors share one behaviour.
|
|
712
|
+
def dispatch_shadow_reply(id, parsed)
|
|
713
|
+
@command_bus.dispatch(
|
|
714
|
+
Insika::Command.build(:record_shadow_reply,
|
|
715
|
+
{ channel: id.to_s, external_id: parsed[:external_id],
|
|
716
|
+
event_id: parsed[:event_id], reply: parsed[:incumbent_reply] || parsed[:reply],
|
|
717
|
+
at: parsed[:at] }.compact,
|
|
718
|
+
transport: :"channel:#{id}")
|
|
719
|
+
)
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# 202 -> 200 {task_id, shadow: true}: a consumer wired to "202 means a
|
|
723
|
+
# reply is coming" must not be silently misled. The duplicate/merged/
|
|
724
|
+
# steered verdicts pass through untouched (they already say 200).
|
|
725
|
+
def shadow_ack(ack)
|
|
726
|
+
status, _headers, body = ack
|
|
727
|
+
return ack unless status == 202
|
|
728
|
+
|
|
729
|
+
json_response(200, JSON.parse(body.join).merge("shadow" => true))
|
|
539
730
|
end
|
|
540
731
|
|
|
541
732
|
# POST /channels/:id/sessions — mint a conversation for a PUBLIC Shape A
|
|
@@ -780,6 +971,36 @@ TENANT_SURFACES = [
|
|
|
780
971
|
# happens here, BEFORE the SSE opens -> closes the subscription and propagates to the
|
|
781
972
|
# #call rescue (becomes an HTTP status).
|
|
782
973
|
def message_flow(payload, stream:, serialize: nil, transport: :http, tenant: nil)
|
|
974
|
+
# WS9: content parts are CONTRACT at the edge — the closed shape is
|
|
975
|
+
# validated here (422) before the command is built; the engine stays
|
|
976
|
+
# lenient for transports that bypass this surface.
|
|
977
|
+
parts = payload[:parts] || payload["parts"]
|
|
978
|
+
if parts && !Insika::Media.well_formed?(parts)
|
|
979
|
+
raise Insika::ValidationError,
|
|
980
|
+
"malformed content part — each part must be {type: text|image|audio|document} with text/url"
|
|
981
|
+
end
|
|
982
|
+
# WS9: the only declared message source is "voice" (pre-transcribed
|
|
983
|
+
# audio) — a consumer that writes prose here is told so, not silently.
|
|
984
|
+
source = payload[:source] || payload["source"]
|
|
985
|
+
if source && source.to_s != "voice"
|
|
986
|
+
raise Insika::ValidationError, 'source must be "voice"'
|
|
987
|
+
end
|
|
988
|
+
# WS9 (saída): the channel's declared OUTPUT media capabilities. The
|
|
989
|
+
# closed list IS the "abstraction admits only what leaks" rule: an
|
|
990
|
+
# unknown value is refused here (422), never silently ignored; a
|
|
991
|
+
# declared value is what the executor may wire (generate_image/tts).
|
|
992
|
+
channel = payload[:channel] || payload["channel"]
|
|
993
|
+
if channel.is_a?(Hash)
|
|
994
|
+
caps = Insika::Media.channel_capabilities(channel)
|
|
995
|
+
unknown = caps - Insika::Media::OUTPUT_CAPABILITIES
|
|
996
|
+
unless unknown.empty?
|
|
997
|
+
raise Insika::ValidationError,
|
|
998
|
+
"unknown channel capability: #{unknown.join(', ')}"
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
payload = payload.dup
|
|
1002
|
+
payload[:channel] = { capabilities: caps }
|
|
1003
|
+
end
|
|
783
1004
|
# WS1: a tenant's session_id is NAMESPACED before the command is built,
|
|
784
1005
|
# so the turn lands on the tenant's OWN session even when another tenant
|
|
785
1006
|
# uses the same chat id. The payload the caller sent is untouched.
|
|
@@ -993,6 +1214,15 @@ TENANT_SURFACES = [
|
|
|
993
1214
|
# touch a store (health cannot fail on IO nor require auth).
|
|
994
1215
|
def health = json_response(200, { status: "ok" })
|
|
995
1216
|
|
|
1217
|
+
# process vitals. Operator-only by construction: not in
|
|
1218
|
+
# PUBLIC_ROUTES (no bearer -> unauthorized) and not in TENANT_SURFACES
|
|
1219
|
+
# (a tenant principal hits the operator-surface gate), so only an
|
|
1220
|
+
# operator reads process internals. Reads no store — pure OS/VM
|
|
1221
|
+
# readings, safe at any rate.
|
|
1222
|
+
def handle_vitals
|
|
1223
|
+
json_response(200, Insika::Vitals.snapshot(executor: @executor, db_path: @db_path))
|
|
1224
|
+
end
|
|
1225
|
+
|
|
996
1226
|
# Thin Subscription decorator: discards
|
|
997
1227
|
# events from OTHER tasks and CLOSES the subscription after forwarding the task's
|
|
998
1228
|
# terminal event. Solves the subscribe-before-task_id gap without touching
|