insika 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# loop detection by (tool, args) hash, with a ONE-SHOT intervention.
|
|
5
|
+
#
|
|
6
|
+
# `max_tool_calls` bounds how MANY tool calls a turn makes, not how useful they
|
|
7
|
+
# are: a model retrying the exact same call — same tool, identical arguments —
|
|
8
|
+
# after an empty or error result burns the whole budget doing something that
|
|
9
|
+
# was settled on the first repeat. This detector is the engine saying so, once.
|
|
10
|
+
#
|
|
11
|
+
# The streak is CONSECUTIVE and turn-scoped, like the max_tool_calls counter it
|
|
12
|
+
# sits next to in ChatBuilder#wire_callbacks: a call revisited much later in a
|
|
13
|
+
# long turn is not the pathology being caught, and semantic ("nearly the same")
|
|
14
|
+
# matching is how a guard-rail starts eating legitimate retries.
|
|
15
|
+
#
|
|
16
|
+
# Two invariants, both borrowed from SteerInjector, because the
|
|
17
|
+
# intervention is a `user` message appended mid-loop:
|
|
18
|
+
#
|
|
19
|
+
# · **Batch boundary only.** The append happens after the LAST tool result of a
|
|
20
|
+
# batch closes — a `user` message between two tool results is rejected by
|
|
21
|
+
# Anthropic outright. Same arithmetic: an assistant message opens a batch of
|
|
22
|
+
# N, the Nth `role: tool` message closes it.
|
|
23
|
+
# · **A halted batch receives nothing.** With `halt_when` there is no next
|
|
24
|
+
# model step; a warning appended there would sit unanswered forever.
|
|
25
|
+
#
|
|
26
|
+
# The repeated call itself STILL RUNS — fabricating a synthetic result would
|
|
27
|
+
# teach the model that tools lie (the failure refuses). The
|
|
28
|
+
# warning rides after the truth; only a repeat that arrives AFTER the warning
|
|
29
|
+
# was spent aborts, through the existing TimeoutError(stage: :tool_limit) path.
|
|
30
|
+
class LoopDetector
|
|
31
|
+
# The one intervention text, verbatim — a fixed engine sentence, so a report
|
|
32
|
+
# can identify it without an origin stamp (chat messages carry none).
|
|
33
|
+
def self.intervention(name, streak)
|
|
34
|
+
"You have called `#{name}` with identical arguments #{streak} times in a row and " \
|
|
35
|
+
"received the same result every time. Repeating it will not produce new information. " \
|
|
36
|
+
"Do not call it again with the same arguments — answer with what you already have, " \
|
|
37
|
+
"or change your approach."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# chat: the turn's chat — must answer #add_message (the boundary append).
|
|
41
|
+
# limit: the streak that triggers the intervention (profile's
|
|
42
|
+
# max_tool_repeat). Values < 2 mean OFF: a "streak of 1" is every
|
|
43
|
+
# call, which is meaningless.
|
|
44
|
+
# emit: ->(type, data) — the Executor's emitter, bound to the task.
|
|
45
|
+
def initialize(chat:, limit:, emit:)
|
|
46
|
+
@chat = chat
|
|
47
|
+
@limit = limit
|
|
48
|
+
@emit = emit
|
|
49
|
+
@last = nil # fingerprint of the previous call (nil = none yet)
|
|
50
|
+
@streak = 0
|
|
51
|
+
@intervened = false # the ONE warning of this turn has been delivered
|
|
52
|
+
@pending = false # detection fired; waiting for the batch boundary
|
|
53
|
+
@expected = nil # tool calls announced by the batch in flight
|
|
54
|
+
@seen = 0
|
|
55
|
+
@halted = false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# From ChatBuilder's before_tool_call. Raises BEFORE the call executes once
|
|
59
|
+
# the warning is spent — bounded spend is the point of aborting here.
|
|
60
|
+
def tool_call(name, arguments)
|
|
61
|
+
fingerprint = [name.to_s, canonical(arguments)]
|
|
62
|
+
if fingerprint == @last
|
|
63
|
+
@streak += 1
|
|
64
|
+
else
|
|
65
|
+
# A different call broke the run: the loop resolved itself, so a warning
|
|
66
|
+
# armed earlier is moot — it must not fire later naming the WRONG call.
|
|
67
|
+
@streak = 1
|
|
68
|
+
@pending = false
|
|
69
|
+
end
|
|
70
|
+
@last = fingerprint
|
|
71
|
+
return if @streak < @limit
|
|
72
|
+
|
|
73
|
+
if @intervened
|
|
74
|
+
raise Insika::TimeoutError.new(
|
|
75
|
+
"tool loop detected (#{name} repeated with identical arguments after a warning)",
|
|
76
|
+
stage: :tool_limit)
|
|
77
|
+
end
|
|
78
|
+
@pending = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# From ChatBuilder's after_tool_result, with the RAW result — the only place
|
|
82
|
+
# a Tool::Halt is still recognizable (SteerInjector's comment applies here).
|
|
83
|
+
def tool_result(result)
|
|
84
|
+
@halted = true if defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# RubyLLM after_message. An assistant message carrying tool calls OPENS a
|
|
88
|
+
# batch; the Nth tool result CLOSES it — the one boundary where appending
|
|
89
|
+
# is valid.
|
|
90
|
+
def message_ended(message)
|
|
91
|
+
role = field(message, :role).to_s
|
|
92
|
+
return open_batch(message) if role == "assistant"
|
|
93
|
+
return unless role == "tool" && @expected
|
|
94
|
+
|
|
95
|
+
@seen += 1
|
|
96
|
+
intervene! if @seen >= @expected
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def open_batch(message)
|
|
102
|
+
calls = field(message, :tool_calls)
|
|
103
|
+
size = calls.respond_to?(:size) ? calls.size : 0
|
|
104
|
+
# No tool call = the model talking; the turn is ending and a pending
|
|
105
|
+
# warning is moot — the loop resolved itself.
|
|
106
|
+
return @expected = nil if size.zero?
|
|
107
|
+
|
|
108
|
+
@expected = size
|
|
109
|
+
@seen = 0
|
|
110
|
+
@halted = false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def intervene!
|
|
114
|
+
@expected = nil
|
|
115
|
+
return unless @pending
|
|
116
|
+
@pending = false
|
|
117
|
+
return if @halted # nothing will read it (halt_when): drop, never deliver
|
|
118
|
+
|
|
119
|
+
@intervened = true
|
|
120
|
+
name, = @last
|
|
121
|
+
@chat.add_message(role: :user, content: self.class.intervention(name, @streak))
|
|
122
|
+
# Counts and the tool name, never the arguments — order numbers are PII.
|
|
123
|
+
@emit.call(:tool_loop_intervened, { name: name, streak: @streak })
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# (name, args) hash: symbols vs strings and key order must not split an
|
|
127
|
+
# identical call into two fingerprints. Compared with ==, never hashed.
|
|
128
|
+
def canonical(value)
|
|
129
|
+
case value
|
|
130
|
+
when Hash then value.map { |k, v| [k.to_s, canonical(v)] }.sort_by(&:first)
|
|
131
|
+
when Array then value.map { |v| canonical(v) }
|
|
132
|
+
else value
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def field(message, name)
|
|
137
|
+
return message.public_send(name) if message.respond_to?(name)
|
|
138
|
+
return message[name] || message[name.to_s] if message.respond_to?(:[])
|
|
139
|
+
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# MINIMAL MCP client over HTTP JSON-RPC. Discovers the tools
|
|
7
|
+
# of an MCP instance with HTTP transport by making a JSON-RPC 2.0 `tools/list`
|
|
8
|
+
# POST to the instance endpoint, behind the EgressGuard (SSRF — the url comes
|
|
9
|
+
# from editable config). It is the DEFAULT client injected into the
|
|
10
|
+
# McpToolIngestor; tests pass a Fake (duck-typed) in its place.
|
|
11
|
+
#
|
|
12
|
+
# Contract (MCP client duck-type): `#list_tools -> [{name, description,
|
|
13
|
+
# inputSchema}]` — the same MCP envelope that the ToolManifest adapter normalizes.
|
|
14
|
+
#
|
|
15
|
+
# SCOPE (bounded): only the minimal handshake of ONE stateless `tools/list`
|
|
16
|
+
# POST. Does NOT implement the full MCP session lifecycle (initialize/protocol
|
|
17
|
+
# negotiation/session-id/notifications) nor the stdio transport — that is the
|
|
18
|
+
# "real MCP transport", later work (out-of-scope, see spec). It serves
|
|
19
|
+
# simple HTTP MCP servers (direct JSON-RPC) and proves the ingestion seam.
|
|
20
|
+
class McpHttpClient
|
|
21
|
+
JSONRPC_VERSION = "2.0"
|
|
22
|
+
|
|
23
|
+
def initialize(url:, http: Insika::HttpClient.new, egress: Insika::EgressGuard,
|
|
24
|
+
egress_options: {}, headers: {}, timeout: nil)
|
|
25
|
+
@url = url.to_s
|
|
26
|
+
@http = http
|
|
27
|
+
@egress = egress
|
|
28
|
+
@egress_options = egress_options
|
|
29
|
+
@headers = { "Content-Type" => "application/json", "Accept" => "application/json" }.merge(headers || {})
|
|
30
|
+
@timeout = timeout
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# -> [ { "name", "description", "inputSchema" } ]. Raises Insika::Error on
|
|
34
|
+
# blocked egress, HTTP != 2xx, invalid JSON or a JSON-RPC error.
|
|
35
|
+
def list_tools
|
|
36
|
+
reason = @egress.violation(@url, **@egress_options)
|
|
37
|
+
raise Insika::Error, "MCP target blocked: #{reason}" if reason
|
|
38
|
+
|
|
39
|
+
result = @http.request(method: "POST", url: @url, headers: @headers,
|
|
40
|
+
body: request_body("tools/list", {}), timeout: @timeout)
|
|
41
|
+
Array(rpc_result(result).fetch("tools", []))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def request_body(method, params)
|
|
47
|
+
JSON.generate(jsonrpc: JSONRPC_VERSION, id: 1, method: method, params: params)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Validates the JSON-RPC envelope and returns `result` (Hash). HTTP/parse/error -> Error.
|
|
51
|
+
def rpc_result(result)
|
|
52
|
+
status = result[:status].to_i
|
|
53
|
+
raise Insika::Error, "MCP HTTP #{status}: #{result[:body].to_s[0, 200]}" if status >= 400
|
|
54
|
+
|
|
55
|
+
parsed = begin
|
|
56
|
+
JSON.parse(result[:body].to_s)
|
|
57
|
+
rescue JSON::ParserError => e
|
|
58
|
+
raise Insika::Error, "MCP response is not JSON: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
if (err = parsed["error"])
|
|
61
|
+
raise Insika::Error, "MCP JSON-RPC error: #{err["message"] || err.inspect}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
parsed["result"] || {}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# MCP instances authored at runtime. One
|
|
5
|
+
# record per instance in the ConfigStore (scope "mcp"), keyed by the name slug
|
|
6
|
+
# (`tavily`, `github`, ...). Holds transport/command/url, the `enabled` flag and
|
|
7
|
+
# a Hash of `env` credentials (tokens/keys the instance injects into the server).
|
|
8
|
+
#
|
|
9
|
+
# The credentials (`env`) NEVER leave here in plaintext to the UI: the display
|
|
10
|
+
# reads (`get`/`all`) mask EACH value with the `__OCULTO__` sentinel. Only
|
|
11
|
+
# `get_raw`/`all_raw` (consumed by an MCP client, never by the screen) return
|
|
12
|
+
# the real values. On write, the sentinel coming back preserves the value; a new
|
|
13
|
+
# string replaces it; "" clears it (see Insika::SecretMasking, the same pattern
|
|
14
|
+
# as the LLM api_keys).
|
|
15
|
+
#
|
|
16
|
+
# Current scope: durable config CRUD (the instances UI). Running an MCP client
|
|
17
|
+
# against these instances is later runtime work — the store is the editable
|
|
18
|
+
# source from now on.
|
|
19
|
+
class McpStore
|
|
20
|
+
include Coercion
|
|
21
|
+
|
|
22
|
+
SCOPE = "mcp"
|
|
23
|
+
|
|
24
|
+
def initialize(config_store:)
|
|
25
|
+
@cs = config_store
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# -> MASKED Hash (env with sentinel) | nil.
|
|
29
|
+
def get(name)
|
|
30
|
+
mask(raw(name))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# -> Hash with REAL env | nil. Internal use (MCP client), never the screen.
|
|
34
|
+
def get_raw(name)
|
|
35
|
+
raw(name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# -> [String] slugs, lexicographic order.
|
|
39
|
+
def names = @cs.keys(SCOPE)
|
|
40
|
+
|
|
41
|
+
# -> [Hash] all MASKED (for the UI).
|
|
42
|
+
def all
|
|
43
|
+
names.filter_map { |n| get(n) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# -> [Hash] all with REAL env (for an MCP client). Never goes to the screen.
|
|
47
|
+
def all_raw
|
|
48
|
+
names.filter_map { |n| raw(n) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Upsert with per-env-key secret reconciliation. `attrs`
|
|
52
|
+
# (string|symbol keys):
|
|
53
|
+
# name (required), transport, command, url, description,
|
|
54
|
+
# enabled (bool), env ({ "KEY" => value|sentinel|"" })
|
|
55
|
+
# -> MASKED Hash (the stored record).
|
|
56
|
+
def upsert(attrs)
|
|
57
|
+
h = symbolize(attrs)
|
|
58
|
+
name = presence(h[:name])
|
|
59
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
60
|
+
|
|
61
|
+
existing = raw(name)
|
|
62
|
+
record = {
|
|
63
|
+
"name" => name,
|
|
64
|
+
"transport" => presence(h[:transport]) || "stdio",
|
|
65
|
+
"command" => presence(h[:command]),
|
|
66
|
+
"url" => presence(h[:url]),
|
|
67
|
+
"description" => presence(h[:description]),
|
|
68
|
+
"enabled" => h.fetch(:enabled, true) ? true : false,
|
|
69
|
+
"env" => reconcile_env(h[:env], existing && existing["env"])
|
|
70
|
+
}
|
|
71
|
+
@cs.put(SCOPE, name, record)
|
|
72
|
+
mask(record)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -> bool (did it exist?).
|
|
76
|
+
def delete(name) = @cs.delete(SCOPE, name.to_s)
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def raw(name) = @cs.get(SCOPE, name.to_s)
|
|
81
|
+
|
|
82
|
+
# Each env value becomes the sentinel (or disappears if empty) — never leaks plaintext.
|
|
83
|
+
def mask(record)
|
|
84
|
+
return nil if record.nil?
|
|
85
|
+
|
|
86
|
+
env = (record["env"] || {}).each_with_object({}) do |(k, v), acc|
|
|
87
|
+
acc[k] = SecretMasking.mask(v)
|
|
88
|
+
end
|
|
89
|
+
record.merge("env" => env)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Reconciles the env received from the form against the stored one, key by key: a
|
|
93
|
+
# key that came as the sentinel is preserved; a new string replaces it; "" (or missing
|
|
94
|
+
# from the submission) clears it. NEW keys are added; old keys absent from the form
|
|
95
|
+
# are removed (the form sends the complete set of keys).
|
|
96
|
+
def reconcile_env(incoming, existing)
|
|
97
|
+
inc = stringify_hash(incoming)
|
|
98
|
+
old = existing || {}
|
|
99
|
+
inc.each_with_object({}) do |(k, v), acc|
|
|
100
|
+
value = SecretMasking.reconcile(v, old[k])
|
|
101
|
+
acc[k] = value unless value.nil? || value.to_s.empty?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def stringify_hash(obj)
|
|
106
|
+
return {} unless obj.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def symbolize(attrs)
|
|
112
|
+
(attrs || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# LIVE MCP ingestion (/ spec): discovers the tools of an
|
|
7
|
+
# MCP instance at RUNTIME (no hand-written manifest) and ingests them as
|
|
8
|
+
# data-tools. Given an McpStore instance + an INJECTABLE MCP client
|
|
9
|
+
# (duck-typed: `#list_tools -> [{name, description, inputSchema}]`), it builds a
|
|
10
|
+
# ToolManifest and REUSES the ingestion path (the:import_tools Command:
|
|
11
|
+
# batch upsert into the ToolStore + hot reload + per-tool report + partial-
|
|
12
|
+
# failure isolation R4). The ToolManifest MCP adapter (`inputSchema`) is reused
|
|
13
|
+
# — no schema parsing here.
|
|
14
|
+
#
|
|
15
|
+
# GENERIC: nothing here mentions a consumer/gateway. The MCP instance is DATA in the store.
|
|
16
|
+
#
|
|
17
|
+
# BINDING STRATEGY (this stage's choice, bounded):
|
|
18
|
+
# Each discovered tool becomes an HTTP data-tool that makes a JSON-RPC 2.0
|
|
19
|
+
# `tools/call` POST to the instance endpoint (url). The tool name is resolved
|
|
20
|
+
# at INGESTION (literal in the body); the model's arguments come in as `{{param}}`
|
|
21
|
+
# per TOP-level property of the inputSchema (with quoting by type — strings
|
|
22
|
+
# quoted, others raw, via the DataDefinedTool :body encode). This way the tool
|
|
23
|
+
# runs through the SAME HTTP path as the other data-tools (egress guard, secret
|
|
24
|
+
# headers, hot reload) — no new execution code.
|
|
25
|
+
#
|
|
26
|
+
# Each tool gets `group: "mcp:<instance>"` so the per-group gating
|
|
27
|
+
# (tools_allow_groups) works for free.
|
|
28
|
+
#
|
|
29
|
+
# DEFERRED / OUT-OF-SCOPE (documented — spec):
|
|
30
|
+
# - Real MCP transport: only instances with a `url` (http transport) are ingestible;
|
|
31
|
+
# stdio has no HTTP endpoint -> raises a clear error (later work).
|
|
32
|
+
# - MCP session lifecycle (initialize/negotiation/session-id/notifications) and the
|
|
33
|
+
# UNWRAP of the `tools/call` response (`{content:[{type,text}]}`) — the binding
|
|
34
|
+
# makes a stateless POST and returns the raw body (extract body_raw).
|
|
35
|
+
# - Credential injection (the instance `env`) as an auth header in the HTTP
|
|
36
|
+
# binding: the `env` is consumed by a real MCP client (deferred), not mapped
|
|
37
|
+
# to a header here.
|
|
38
|
+
# - Tools whose name/top-level property is outside the ToolDefinition NAME_RE
|
|
39
|
+
# (uppercase/hyphens) are ISOLATED into `errors[]` by the import (R4).
|
|
40
|
+
class McpToolIngestor
|
|
41
|
+
def initialize(mcp_store:, import_tools:, client_factory: nil)
|
|
42
|
+
@mcp_store = mcp_store
|
|
43
|
+
@import_tools = import_tools
|
|
44
|
+
# Per-instance client factory (default: minimal JSON-RPC HTTP client).
|
|
45
|
+
# Injectable for tests (Fake) and to swap for a real transport later.
|
|
46
|
+
@client_factory = client_factory || method(:default_client)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Discovers + ingests the tools of instance `name`. `client` is injectable
|
|
50
|
+
# (Fake in tests); absent -> the factory builds one from the record. -> the
|
|
51
|
+
# import_tools report + `instance:` ({ instance:, version:, created:, updated:, errors: }).
|
|
52
|
+
def ingest(name, client: nil)
|
|
53
|
+
manifest = manifest_for(name, client: client)
|
|
54
|
+
report = @import_tools.call(Insika::Command.build(:import_tools, manifest, transport: :internal))
|
|
55
|
+
report.merge(instance: name.to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Discovers the tools and builds the manifest Hash (without ingesting) — isolable for testing.
|
|
59
|
+
def manifest_for(name, client: nil)
|
|
60
|
+
record = @mcp_store.get_raw(name.to_s)
|
|
61
|
+
raise Insika::NotFoundError, "MCP instance '#{name}' not found" if record.nil?
|
|
62
|
+
raise Insika::ValidationError, "MCP instance '#{name}' is disabled" unless record["enabled"]
|
|
63
|
+
|
|
64
|
+
url = presence(record["url"])
|
|
65
|
+
if url.nil?
|
|
66
|
+
raise Insika::ValidationError,
|
|
67
|
+
"MCP instance '#{name}' has no url: live ingestion requires HTTP transport " \
|
|
68
|
+
"(stdio is later work)"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
tools = Array((client || @client_factory.call(record)).list_tools)
|
|
72
|
+
build_manifest(name.to_s, url, tools)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def build_manifest(name, url, tools)
|
|
78
|
+
{
|
|
79
|
+
"version" => 1,
|
|
80
|
+
"defaults" => {
|
|
81
|
+
"method" => "POST",
|
|
82
|
+
"headers" => { "Content-Type" => "application/json" },
|
|
83
|
+
"response" => { "extract" => "body_raw" },
|
|
84
|
+
"group" => "mcp:#{name}"
|
|
85
|
+
},
|
|
86
|
+
"tools" => tools.map { |raw| tool_entry(name, url, stringify(raw)) }
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Raw MCP entry -> manifest entry (MCP `inputSchema` envelope + JSON-RPC
|
|
91
|
+
# binding). ToolManifest normalizes the `inputSchema` (MCP adapter) and
|
|
92
|
+
# inherits the defaults; `group` falls through from the defaults.
|
|
93
|
+
def tool_entry(name, url, raw)
|
|
94
|
+
tool_name = raw["name"]
|
|
95
|
+
input_schema = raw["inputSchema"] || {}
|
|
96
|
+
{
|
|
97
|
+
"name" => tool_name,
|
|
98
|
+
"description" => presence(raw["description"]) || "Tool '#{tool_name}' from MCP server '#{name}'.",
|
|
99
|
+
"inputSchema" => input_schema,
|
|
100
|
+
"url" => url,
|
|
101
|
+
"side_effect" => true, # a tools/call is a side effect (checkpoint/skip-on-resume)
|
|
102
|
+
"body" => jsonrpc_call_body(tool_name, input_schema)
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# JSON-RPC 2.0 `tools/call` body. `name` literal (resolved at ingestion);
|
|
107
|
+
# `arguments` per TOP-level property of the inputSchema, with `{{param}}` that
|
|
108
|
+
# the DataDefinedTool interpolates at TURN time.
|
|
109
|
+
def jsonrpc_call_body(tool_name, input_schema)
|
|
110
|
+
%({"jsonrpc":"2.0","id":1,"method":"tools/call",) +
|
|
111
|
+
%("params":{"name":#{JSON.generate(tool_name)},"arguments":#{arguments_fragment(input_schema)}}})
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# -> "{...}" JSON with one placeholder per top-level property. Strings are
|
|
115
|
+
# quoted (the DataDefinedTool :body encode returns the escaped content WITHOUT
|
|
116
|
+
# quotes); other types raw (the encode returns value.to_json).
|
|
117
|
+
def arguments_fragment(input_schema)
|
|
118
|
+
props = (input_schema["properties"] || input_schema[:properties] || {})
|
|
119
|
+
return "{}" if props.nil? || props.empty?
|
|
120
|
+
|
|
121
|
+
pairs = props.map do |key, spec|
|
|
122
|
+
type = stringify(spec)["type"].to_s
|
|
123
|
+
placeholder = type == "string" ? %("{{#{key}}}") : "{{#{key}}}"
|
|
124
|
+
%(#{JSON.generate(key.to_s)}:#{placeholder})
|
|
125
|
+
end
|
|
126
|
+
"{#{pairs.join(',')}}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def default_client(record)
|
|
130
|
+
Insika::McpHttpClient.new(url: record["url"])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def presence(str) = Insika::Coercion.presence(str)
|
|
134
|
+
|
|
135
|
+
def stringify(obj)
|
|
136
|
+
case obj
|
|
137
|
+
when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify(v) }
|
|
138
|
+
when Array then obj.map { |v| stringify(v) }
|
|
139
|
+
else obj
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# AGENT-MEMORY DOMAIN store. Two layers
|
|
8
|
+
# scoped per tenant over any `Insika::Store`: `profile` (stable key-value
|
|
9
|
+
# facts) and `notes` (append-only free-form notes). Mirrors the
|
|
10
|
+
# `PendingActionStore` (normalizes symbol→string on write, O(n) scan on
|
|
11
|
+
# read, records with a timestamp).
|
|
12
|
+
#
|
|
13
|
+
# NOT to be confused with `Insika::Stores::Memory` (in-memory KV backend):
|
|
14
|
+
# this is the domain store; that one is one of the backends this writes to.
|
|
15
|
+
class MemoryStore
|
|
16
|
+
SCOPE_PREFIX = "memory" # scope = "memory:<tenant>"
|
|
17
|
+
FACT_PREFIX = "fact:"
|
|
18
|
+
NOTE_PREFIX = "note:"
|
|
19
|
+
DEFAULT_TENANT = "_default" # no tenant in the Command
|
|
20
|
+
|
|
21
|
+
Fact = Data.define(:key, :value, :updated_at)
|
|
22
|
+
Note = Data.define(:id, :text, :created_at)
|
|
23
|
+
|
|
24
|
+
def initialize(store:)
|
|
25
|
+
@store = store
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Upsert (last-write-wins, Store contract). -> Fact
|
|
29
|
+
def put_fact(tenant:, key:, value:)
|
|
30
|
+
record = { "key" => key.to_s, "value" => stringify(value), "updated_at" => timestamp }
|
|
31
|
+
@store.set(scope_for(tenant), FACT_PREFIX + key.to_s, record)
|
|
32
|
+
to_fact(record)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# -> Fact | nil
|
|
36
|
+
def get_fact(tenant:, key:)
|
|
37
|
+
record = @store.get(scope_for(tenant), FACT_PREFIX + key.to_s)
|
|
38
|
+
record && to_fact(record)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# -> [Fact] sorted by key (list is lexicographic).
|
|
42
|
+
def facts(tenant:)
|
|
43
|
+
scope = scope_for(tenant)
|
|
44
|
+
@store.list(scope, FACT_PREFIX).filter_map do |k|
|
|
45
|
+
record = @store.get(scope, k)
|
|
46
|
+
record && to_fact(record)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# -> bool (did it exist?)
|
|
51
|
+
def forget_fact(tenant:, key:)
|
|
52
|
+
@store.delete(scope_for(tenant), FACT_PREFIX + key.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Append. `at` (ISO8601) goes at the START of the key so `list` returns the notes in
|
|
56
|
+
# chronological order; `id`/`at` injectable for deterministic tests. -> Note
|
|
57
|
+
def add_note(tenant:, text:, id: SecureRandom.uuid, at: nil)
|
|
58
|
+
at ||= timestamp
|
|
59
|
+
record = { "id" => id.to_s, "text" => text.to_s, "created_at" => at }
|
|
60
|
+
@store.set(scope_for(tenant), NOTE_PREFIX + "#{at}:#{id}", record)
|
|
61
|
+
to_note(record)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# -> [Note] MOST RECENT first, capped by `limit`.
|
|
65
|
+
def notes(tenant:, limit: nil)
|
|
66
|
+
scope = scope_for(tenant)
|
|
67
|
+
keys = @store.list(scope, NOTE_PREFIX).reverse # list is chronological -> reverse = most recent first
|
|
68
|
+
keys = keys.first(limit) if limit
|
|
69
|
+
keys.filter_map do |k|
|
|
70
|
+
record = @store.get(scope, k)
|
|
71
|
+
record && to_note(record)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def scope_for(tenant) = "#{SCOPE_PREFIX}:#{tenant.nil? || tenant.to_s.empty? ? DEFAULT_TENANT : tenant}"
|
|
78
|
+
|
|
79
|
+
def to_fact(record) = Fact.new(key: record["key"], value: record["value"], updated_at: record["updated_at"])
|
|
80
|
+
def to_note(record) = Note.new(id: record["id"], text: record["text"], created_at: record["created_at"])
|
|
81
|
+
|
|
82
|
+
def timestamp = Time.now.utc.iso8601
|
|
83
|
+
|
|
84
|
+
def stringify(obj)
|
|
85
|
+
case obj
|
|
86
|
+
when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify(v) }
|
|
87
|
+
when Array then obj.map { |v| stringify(v) }
|
|
88
|
+
when Symbol then obj.to_s
|
|
89
|
+
else obj
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# WHO PRODUCED A TRANSCRIPT MESSAGE — the field a `role` cannot carry.
|
|
5
|
+
#
|
|
6
|
+
# `role` says where a message sits in the conversation, not who wrote it, and the
|
|
7
|
+
# two come apart constantly:
|
|
8
|
+
#
|
|
9
|
+
# · The engine delivers an async subagent's result to the parent as a NEW turn
|
|
10
|
+
# a `user` message the engine wrote.
|
|
11
|
+
# · A guardrail short-circuits with a safe reply — an `assistant` message produced
|
|
12
|
+
# with zero LLM calls.
|
|
13
|
+
# · A consumer composes context blocks into the input it sends (`<memoria> …`,
|
|
14
|
+
# `<store_cep_required> …`) — a `user` message the customer never typed.
|
|
15
|
+
# · In an imported transcript, a human operator types after a handoff — an
|
|
16
|
+
# `assistant` message no model produced.
|
|
17
|
+
#
|
|
18
|
+
# Reading a transcript without that distinction is not a rounding error. The first
|
|
19
|
+
# refinement run over real traffic reported `repetition ×219` on one agent: every
|
|
20
|
+
# one of them the engine reading its own injected fragment back and calling it a
|
|
21
|
+
# customer repeating themselves (PR #133). That was filtered by a REGEX on the
|
|
22
|
+
# leading tag, labelled in the code as a heuristic standing in for this field.
|
|
23
|
+
#
|
|
24
|
+
# ABSENT is the common case and stays valid forever: a message with no origin is
|
|
25
|
+
# read as the natural producer for its role (`user` → the customer, `assistant` →
|
|
26
|
+
# the agent). Nothing in the existing corpus, the stores or the pilot's database
|
|
27
|
+
# has to be migrated, and a reader that ignores the field is exactly as correct as
|
|
28
|
+
# it was before.
|
|
29
|
+
module MessageOrigin
|
|
30
|
+
KEY = "origin"
|
|
31
|
+
|
|
32
|
+
CUSTOMER = "customer" # a person on the user side (the default for `user`)
|
|
33
|
+
AGENT = "agent" # the model (the default for `assistant`)
|
|
34
|
+
ENGINE = "engine" # Insika itself, or the consumer composing on its behalf
|
|
35
|
+
OPERATOR = "operator" # a HUMAN on the assistant side (a handoff; set by importers)
|
|
36
|
+
|
|
37
|
+
ALL = [CUSTOMER, AGENT, ENGINE, OPERATOR].freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# A declared origin, or nil. Anything outside the closed set is REFUSED rather
|
|
42
|
+
# than stored: an unknown value would silently read as "absent" downstream, and
|
|
43
|
+
# a typo'd marker is worse than none — it looks like the filtering is on.
|
|
44
|
+
def parse!(value)
|
|
45
|
+
return nil if Coercion.blank?(value)
|
|
46
|
+
|
|
47
|
+
name = value.to_s.strip.downcase
|
|
48
|
+
return name if ALL.include?(name)
|
|
49
|
+
|
|
50
|
+
raise Insika::ValidationError,
|
|
51
|
+
"unknown message origin: #{value.inspect} (expected #{ALL.join(', ')})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Did a PERSON on the user side write this? Absent origin = yes, because that is
|
|
55
|
+
# what a `user` message meant before this field existed.
|
|
56
|
+
def customer?(message)
|
|
57
|
+
message["role"].to_s == "user" && [nil, CUSTOMER].include?(origin_of(message))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Did the MODEL write this? Absent origin = yes, same reasoning.
|
|
61
|
+
def agent?(message)
|
|
62
|
+
message["role"].to_s == "assistant" && [nil, AGENT].include?(origin_of(message))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def origin_of(message)
|
|
66
|
+
v = message[KEY] || message[KEY.to_sym]
|
|
67
|
+
Coercion.presence(v)&.downcase
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Stamps a message hash, leaving it untouched when there is nothing to declare —
|
|
71
|
+
# so the common turn keeps producing exactly the two-key shape it always did.
|
|
72
|
+
def stamp(message, origin)
|
|
73
|
+
origin.nil? ? message : message.merge(KEY => origin)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# A link in the chain (stage 4). A Middleware MODIFIES the TurnState,
|
|
5
|
+
# short-circuits, and has operational effect (rate limit, tracing, cost) — it
|
|
6
|
+
# does NOT decide tool/skill permission (that is Policy). Short-circuit =
|
|
7
|
+
# NOT calling `nxt` and setting `state.halt_reason`. Setting halt_reason AND
|
|
8
|
+
# calling nxt is a contract violation (the Executor prioritizes halt_reason on
|
|
9
|
+
# the way back).
|
|
10
|
+
#
|
|
11
|
+
# Concurrency: it runs on the task's fiber; IO (e.g. a tracing exporter)
|
|
12
|
+
# must be async off the path (`Async { ... }` fire-and-forget) or accept
|
|
13
|
+
# the latency in the turn. No timeout of its own (covered by the turn timeout).
|
|
14
|
+
class Middleware
|
|
15
|
+
def call(state, &nxt)
|
|
16
|
+
nxt.call(state) # default link: pass-through
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Rack-like composition: registration order = execution order (the first is
|
|
21
|
+
# the outermost link). It does NOT rescue (an exception propagates as a turn
|
|
22
|
+
# failure) nor does it have a special halt mechanism — the short-circuit is
|
|
23
|
+
# structural (the link does not call nxt).
|
|
24
|
+
class MiddlewareStack
|
|
25
|
+
def initialize(middlewares = [])
|
|
26
|
+
@middlewares = middlewares
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(state, &terminal)
|
|
30
|
+
chain = @middlewares.reverse.reduce(terminal) do |nxt, mw|
|
|
31
|
+
proc { |s| mw.call(s, &nxt) }
|
|
32
|
+
end
|
|
33
|
+
chain.call(state)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|