insika 0.8.0 → 1.0.1
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 +312 -2
- data/README.md +10 -6
- data/bin/insika +4 -1
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +142 -18
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +56 -50
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +96 -27
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +70 -13
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +129 -10
- data/docs/FACTS.md +12 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +14 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +164 -28
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +26 -14
- data/docs/RELEASING.md +9 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +34 -1
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +238 -42
- data/docs/operate.md +3 -0
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +3 -1
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +124 -28
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_distillation.rb +5 -8
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +128 -0
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/builder.rb +29 -9
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +7 -2
- data/lib/insika/context/providers/briefing.rb +11 -8
- data/lib/insika/context/providers/fence_notice.rb +27 -0
- data/lib/insika/context/providers/knowledge.rb +32 -6
- data/lib/insika/context/providers/memory.rb +61 -8
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +46 -6
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +142 -4
- data/lib/insika/dsl/runtime.rb +37 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +19 -5
- data/lib/insika/edge_limiter.rb +27 -16
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +259 -18
- data/lib/insika/evals/golden.rb +230 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +64 -4
- data/lib/insika/evals/simulator.rb +21 -3
- data/lib/insika/evals/transport.rb +317 -15
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +105 -17
- data/lib/insika/executor.rb +698 -164
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +37 -3
- data/lib/insika/mcp_tool_registry.rb +76 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +16 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +30 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +5 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +58 -7
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +50 -5
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +163 -11
- data/lib/insika/settings_store.rb +57 -2
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/spoken_transcript.rb +31 -0
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1459 -163
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +189 -12
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +153 -118
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +115 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +82 -13
- data/lib/insika/tool_envelope.rb +266 -29
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +12 -4
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +14 -4
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +24 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +115 -0
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +9 -6
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +11 -7
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +31 -1
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +88 -16
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +23 -3
- metadata +74 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
|
@@ -20,7 +20,7 @@ module Insika
|
|
|
20
20
|
# "group" => string|nil, "tags" => ["b2b",...] } #//
|
|
21
21
|
#
|
|
22
22
|
# `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP):
|
|
23
|
-
# a nestable object, fed straight into RubyLLM's `
|
|
23
|
+
# a nestable object, fed straight into RubyLLM's `parameters_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
|
|
26
26
|
# `array:<scalar>` — it CANNOT express an array of objects, and says so instead of
|
|
@@ -35,10 +35,17 @@ module Insika
|
|
|
35
35
|
ToolDefinition = Data.define(
|
|
36
36
|
:name, :description, :parameters, :request, :response,
|
|
37
37
|
:secret_headers, :side_effect, :timeout, :group, :tags, :halt_when,
|
|
38
|
-
:evidence
|
|
38
|
+
:evidence, # Insika::Evidence::Spec | nil
|
|
39
|
+
:requires_evidence, # { "params" => [String] } | nil
|
|
40
|
+
:presentation # { component:, ids:, max: } | nil — a UI tool, no HTTP
|
|
39
41
|
)
|
|
40
42
|
|
|
41
43
|
class ToolDefinition
|
|
44
|
+
# Terminal tool results finish the current batch without another model request.
|
|
45
|
+
Halt = Data.define(:content) do
|
|
46
|
+
def to_s = content.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
42
49
|
# Flat-sugar types: the SCALARS, plus `array:<scalar>` for a list. There is no bare
|
|
43
50
|
# `array`: an array without an item type is an INCOMPLETE declaration, and the
|
|
44
51
|
# engine refuses to guess one (see lift_flat_params).
|
|
@@ -73,9 +80,9 @@ module Insika
|
|
|
73
80
|
# Builds + validates. Raises Insika::ValidationError. Accepts keyword args
|
|
74
81
|
# (already-normalized symbol keys); use from_h for a raw Hash from the store/UI.
|
|
75
82
|
# `parameters` accepts JSON Schema (Hash) OR the legacy flat array.
|
|
76
|
-
def self.build(name:, description:, request
|
|
83
|
+
def self.build(name:, description:, request: nil, parameters: nil, response: nil,
|
|
77
84
|
secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
|
|
78
|
-
halt_when: nil, evidence: nil)
|
|
85
|
+
halt_when: nil, evidence: nil, presentation: nil, requires_evidence: nil)
|
|
79
86
|
name = name.to_s
|
|
80
87
|
raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)
|
|
81
88
|
|
|
@@ -83,15 +90,21 @@ module Insika
|
|
|
83
90
|
raise Insika::ValidationError, "description is required" if desc.empty?
|
|
84
91
|
|
|
85
92
|
schema = normalize_params(parameters)
|
|
86
|
-
|
|
93
|
+
# A tool is EITHER an HTTP call or a presentation — never both, never neither.
|
|
94
|
+
if request && presentation
|
|
95
|
+
raise Insika::ValidationError, "a tool declares either 'request' or 'presentation', not both"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
pres = normalize_presentation(presentation, schema)
|
|
99
|
+
req = pres ? nil : normalize_request(request || {}, top_level_names(schema))
|
|
87
100
|
resp = normalize_response(response)
|
|
88
101
|
if resp[:extract] == "evidence_envelope" && evidence.nil?
|
|
89
102
|
raise Insika::ValidationError,
|
|
90
103
|
"extract 'evidence_envelope' requires an 'evidence' declaration"
|
|
91
104
|
end
|
|
92
105
|
|
|
93
|
-
method = req[:method]
|
|
94
|
-
effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)
|
|
106
|
+
method = req && req[:method]
|
|
107
|
+
effect = side_effect.nil? ? !(method.nil? || IDEMPOTENT.include?(method)) : (side_effect ? true : false)
|
|
95
108
|
|
|
96
109
|
new(
|
|
97
110
|
name: name, description: desc, parameters: schema, request: req, response: resp,
|
|
@@ -99,7 +112,9 @@ module Insika
|
|
|
99
112
|
timeout: timeout.nil? ? nil : Integer(timeout),
|
|
100
113
|
group: normalize_group(group), tags: normalize_tags(tags),
|
|
101
114
|
halt_when: normalize_halt_when(halt_when),
|
|
102
|
-
evidence: Insika::Evidence::Spec.parse(evidence)
|
|
115
|
+
evidence: Insika::Evidence::Spec.parse(evidence),
|
|
116
|
+
presentation: pres,
|
|
117
|
+
requires_evidence: normalize_requires_evidence(requires_evidence, top_level_names(schema))
|
|
103
118
|
)
|
|
104
119
|
end
|
|
105
120
|
|
|
@@ -108,12 +123,64 @@ module Insika
|
|
|
108
123
|
h = deep_symbolize(hash)
|
|
109
124
|
build(
|
|
110
125
|
name: h[:name], description: h[:description], parameters: h[:parameters],
|
|
111
|
-
request: h[:request]
|
|
126
|
+
request: h[:request], response: h[:response],
|
|
112
127
|
secret_headers: h[:secret_headers], side_effect: h[:side_effect], timeout: h[:timeout],
|
|
113
|
-
group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence]
|
|
128
|
+
group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence],
|
|
129
|
+
presentation: h[:presentation], requires_evidence: h[:requires_evidence]
|
|
114
130
|
)
|
|
115
131
|
end
|
|
116
132
|
|
|
133
|
+
# PRESENTATION: a tool whose job is to SHOW something, not to fetch it. The model
|
|
134
|
+
# picks ids, the engine validates them against the session's evidence ledger and
|
|
135
|
+
# joins the cards an evidence tool already returned (Tools::Present). No HTTP.
|
|
136
|
+
#
|
|
137
|
+
# "presentation" => { "component" => "product_cards", # what the channel renders
|
|
138
|
+
# "ids" => "product_ids", # the array:string param
|
|
139
|
+
# "max" => 8 } # 1..16, the attachment cap
|
|
140
|
+
#
|
|
141
|
+
# -> { component:, ids:, max: } | nil
|
|
142
|
+
def self.normalize_presentation(raw, schema)
|
|
143
|
+
return nil if raw.nil?
|
|
144
|
+
|
|
145
|
+
h = deep_symbolize(raw)
|
|
146
|
+
raise Insika::ValidationError, "presentation must be an object" unless h.is_a?(Hash)
|
|
147
|
+
|
|
148
|
+
component = h[:component].to_s
|
|
149
|
+
unless NAME_RE.match?(component)
|
|
150
|
+
raise Insika::ValidationError, "presentation.component must match #{NAME_RE.inspect}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
ids = h[:ids].to_s
|
|
154
|
+
prop = (schema["properties"] || {})[ids]
|
|
155
|
+
unless prop.is_a?(Hash) && prop["type"] == "array" && prop.dig("items", "type") == "string"
|
|
156
|
+
raise Insika::ValidationError,
|
|
157
|
+
"presentation.ids must name a declared array:string parameter (got #{ids.inspect})"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
max = h[:max].nil? ? Insika::Evidence::MAX_ATTACHMENTS : Integer(h[:max], exception: false)
|
|
161
|
+
unless max.is_a?(Integer) && max.between?(1, Insika::Evidence::MAX_ATTACHMENTS)
|
|
162
|
+
raise Insika::ValidationError, "presentation.max must be 1..#{Insika::Evidence::MAX_ATTACHMENTS}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
{ component: component, ids: ids, max: max }
|
|
166
|
+
end
|
|
167
|
+
private_class_method :normalize_presentation
|
|
168
|
+
|
|
169
|
+
def presentation? = !presentation.nil?
|
|
170
|
+
|
|
171
|
+
# Public because an MCP tool has the same question to answer and there must be
|
|
172
|
+
# one reading of "these parameters may only carry an id some tool returned".
|
|
173
|
+
def self.normalize_requires_evidence(value, param_names)
|
|
174
|
+
return nil if value.nil?
|
|
175
|
+
|
|
176
|
+
params = value.is_a?(Hash) ? deep_symbolize(value)[:params] : value
|
|
177
|
+
unless params.is_a?(Array) && !params.empty? && params.all? { |p| param_names.include?(p.to_s) }
|
|
178
|
+
raise Insika::ValidationError, "requires_evidence needs a non-empty list of declared top-level parameters"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
{ "params" => params.map(&:to_s).uniq }
|
|
182
|
+
end
|
|
183
|
+
|
|
117
184
|
# Group: enablement label by DATA (not name convention),
|
|
118
185
|
# target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
|
|
119
186
|
def self.normalize_group(group)
|
|
@@ -414,7 +481,7 @@ module Insika
|
|
|
414
481
|
h = {
|
|
415
482
|
"name" => name, "description" => description,
|
|
416
483
|
"parameters" => parameters,
|
|
417
|
-
"request" => request
|
|
484
|
+
"request" => request&.transform_keys(&:to_s),
|
|
418
485
|
"response" => response.transform_keys(&:to_s),
|
|
419
486
|
"secret_headers" => secret_headers,
|
|
420
487
|
"side_effect" => side_effect, "timeout" => timeout,
|
|
@@ -424,6 +491,8 @@ module Insika
|
|
|
424
491
|
# present only when declared — a tool without evidence is byte-identical
|
|
425
492
|
# to today (no declaration, no envelope processing).
|
|
426
493
|
h["evidence"] = evidence.to_h if evidence
|
|
494
|
+
h["requires_evidence"] = requires_evidence if requires_evidence
|
|
495
|
+
h["presentation"] = presentation.transform_keys(&:to_s) if presentation
|
|
427
496
|
h
|
|
428
497
|
end
|
|
429
498
|
|
|
@@ -479,7 +548,7 @@ module Insika
|
|
|
479
548
|
nil
|
|
480
549
|
end
|
|
481
550
|
|
|
482
|
-
# HOW `say` REACHES THE EXECUTOR.
|
|
551
|
+
# HOW `say` REACHES THE EXECUTOR. Insika::ToolDefinition::Halt carries one value, and
|
|
483
552
|
# that value is the tool's payload (the trace records it, and the model never
|
|
484
553
|
# sees it — the halt ends the loop). So a halt that has something to publish
|
|
485
554
|
# carries BOTH, under keys distinctive enough that a trace reader knows what
|
|
@@ -513,7 +582,7 @@ module Insika
|
|
|
513
582
|
|
|
514
583
|
# FLAT view of the top-level properties (name/type/description/required) — for
|
|
515
584
|
# RubyLLM's `#parameters` (discovery/tool_search) and the simple authoring UI.
|
|
516
|
-
# The full nested schema goes through `
|
|
585
|
+
# The full nested schema goes through `parameters_schema` (DataDefinedTool). Symbol-
|
|
517
586
|
# keyed for compat with callers that already consumed the flat params.
|
|
518
587
|
def top_level_params
|
|
519
588
|
props = parameters["properties"] || {}
|
data/lib/insika/tool_envelope.rb
CHANGED
|
@@ -9,9 +9,29 @@ module Insika
|
|
|
9
9
|
# + recording of a non-idempotent side-effect BEFORE the result returns to the
|
|
10
10
|
# model. Delegates everything else (name/description/params) to the real tool.
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
# the Executor never drives roundtrips.
|
|
12
|
+
# RubyLLM executes tools; this decorator enforces Insika's per-call policy.
|
|
14
13
|
class ToolEnvelope < SimpleDelegator
|
|
14
|
+
# Fiber-local slot where a tool leaves its timing breakdown for this call's trace.
|
|
15
|
+
TIMING_KEY = :insika_tool_timing
|
|
16
|
+
|
|
17
|
+
PROVENANCE_INSTRUCTION = "This value was not returned by any tool in this conversation. " \
|
|
18
|
+
"Find it with a tool that returns it — a search or a lookup by id — " \
|
|
19
|
+
"then call this tool again with an id from that result."
|
|
20
|
+
|
|
21
|
+
# The words the model reads when a call is held. They are the only thing
|
|
22
|
+
# standing between a correct store and a customer who was told the opposite:
|
|
23
|
+
# the engine can refuse the write, it cannot refuse the sentence. Measured —
|
|
24
|
+
# one reply in twenty announced a held purchase as closed — so the reply's
|
|
25
|
+
# SHAPE is stated before anything else, and the lie is named concretely
|
|
26
|
+
# rather than as "do not say it happened".
|
|
27
|
+
CONFIRMATION_INSTRUCTION = "NOT DONE — nothing was written, and nothing will be until the customer " \
|
|
28
|
+
"answers this very message. Your reply must ASK: say exactly what will " \
|
|
29
|
+
"happen, with these arguments, and end with the question. A reply that " \
|
|
30
|
+
"reports it as finished — 'done', 'closed', 'created', any past tense — is " \
|
|
31
|
+
"false, and the customer will act on it. On their next message: if they " \
|
|
32
|
+
"confirm, call confirm_pending with this pending_id; if they decline, " \
|
|
33
|
+
"change anything, or ask for something else, call cancel_pending."
|
|
34
|
+
|
|
15
35
|
# The tool timeout's OWN class: distinct from Async::TimeoutError so that
|
|
16
36
|
# the rescue below NEVER swallows the TURN timeout (which uses the default of
|
|
17
37
|
# with_timeout). Without this, a turn overflowing while the fiber is inside a
|
|
@@ -20,48 +40,122 @@ module Insika
|
|
|
20
40
|
ToolTimeout = Class.new(StandardError)
|
|
21
41
|
private_constant :ToolTimeout
|
|
22
42
|
|
|
43
|
+
# RubyLLM asks the tool's CLASS whether to defer its definition (tool
|
|
44
|
+
# search), and a delegator's class is this one. No Insika tool defers, so
|
|
45
|
+
# every envelope is sent as an ordinary tool.
|
|
46
|
+
def self.deferred? = false
|
|
47
|
+
|
|
48
|
+
# A gate's refusal. A plain Hash subclass: it reaches the model exactly as the
|
|
49
|
+
# `{status:, gate:, ...}` it always was, and the engine's own readers (the
|
|
50
|
+
# :tool_result outcome, the trace) recognize a refusal by CLASS — a data tool
|
|
51
|
+
# answering `{"status":"blocked","gate":"fraud_review"}` for a held order is
|
|
52
|
+
# not one, whatever keys it happens to use.
|
|
53
|
+
class Blocked < Hash; end
|
|
54
|
+
|
|
55
|
+
# A call held for the customer's confirmation. Same Hash-subclass trick as
|
|
56
|
+
# Blocked, and a different class on purpose: nothing was refused and nothing
|
|
57
|
+
# ran — the next customer message decides, through confirm_pending.
|
|
58
|
+
class Held < Hash; end
|
|
59
|
+
|
|
23
60
|
def initialize(tool, state:, checkpoint_store:, tool_registry:, timeout:,
|
|
24
|
-
skip_side_effects: [], trace_recorder: nil)
|
|
61
|
+
skip_side_effects: [], trace_recorder: nil, event_stream: nil)
|
|
25
62
|
super(tool)
|
|
26
63
|
@state = state
|
|
27
64
|
@checkpoint_store = checkpoint_store
|
|
28
65
|
@tool_registry = tool_registry
|
|
29
66
|
@timeout = timeout
|
|
30
|
-
@
|
|
67
|
+
@event_stream = event_stream
|
|
68
|
+
@skip_side_effects = Array(skip_side_effects).dup # ids already completed in the interrupted turn
|
|
31
69
|
@trace_recorder = trace_recorder # duck-type: #record(session_id:, entry:). nil = no trace.
|
|
32
70
|
end
|
|
33
71
|
|
|
34
72
|
# Entry point that RubyLLM invokes (Tool#call in the pinned version).
|
|
35
73
|
# A timeout overflow returns to the MODEL as a serialized error — it does
|
|
36
74
|
# not bring down the turn.
|
|
37
|
-
def call(args)
|
|
75
|
+
def call(args = {}, tool_call: nil, **kwargs)
|
|
76
|
+
@state.current_tool_call = tool_call if tool_call
|
|
77
|
+
run(args.merge(kwargs), hold: true, native_call: tool_call && native_approvals?)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def requires_approval? = native_approvals? && approval_required?
|
|
81
|
+
|
|
82
|
+
def approval_tool_name = real_name
|
|
83
|
+
|
|
84
|
+
# RubyLLM queries this while inspecting and partitioning pending calls.
|
|
85
|
+
# Keep it read-only; policy refusals still return through #call as tool results.
|
|
86
|
+
def approval_resolver
|
|
87
|
+
->(call) do
|
|
88
|
+
next true if @skip_side_effects.include?(call.id.to_s) || provenance_block(call.arguments) || customer_confirm?
|
|
89
|
+
|
|
90
|
+
native_approval_decision(call.arguments, call.id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The confirmed re-run of a held call (Tools::ConfirmPending): every check but
|
|
95
|
+
# the hold itself — provenance, approval, fencing, evidence, the side-effect
|
|
96
|
+
# record and the trace all still apply to the write.
|
|
97
|
+
def call_confirmed(args) = run(args, hold: false)
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def run(args, hold:, native_call: false)
|
|
38
102
|
# A non-idempotent tool call ALREADY COMPLETED in the interrupted
|
|
39
103
|
# turn -> respond with a marker, NEVER re-execute. The marker returns to
|
|
40
104
|
# the model, keeping the tool-use protocol intact.
|
|
41
105
|
call_id = correlation_id
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
106
|
+
continuation = native_call && @state.approval_continuation
|
|
107
|
+
if call_id && @skip_side_effects.include?(call_id)
|
|
108
|
+
halt = continuation && continuation.dig("halts", call_id)
|
|
109
|
+
return ToolDefinition::Halt.new(content: halt["content"]) if halt
|
|
110
|
+
|
|
111
|
+
return { "skipped" => "already_executed" }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
started = monotonic
|
|
115
|
+
if (blocked = provenance_block(args))
|
|
116
|
+
trace(call_id, args, blocked, started)
|
|
117
|
+
emit_blocked(blocked)
|
|
118
|
+
return blocked
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Customer confirmation: the call is recorded and returned to the model as
|
|
122
|
+
# held; the turn goes on to ask. After the provenance gate (an id the customer
|
|
123
|
+
# never saw is not put to them as real) and before approval.
|
|
124
|
+
if hold && (held = confirmation_hold(args))
|
|
125
|
+
trace(call_id, args, held, started)
|
|
126
|
+
return held
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Native calls recheck the persisted decision after RubyLLM's resolver.
|
|
130
|
+
# Direct/workflow calls still create and await approval through the mailbox.
|
|
131
|
+
# Cancellation and turn timeout propagate from that wait (not ToolTimeout).
|
|
50
132
|
if approval_required?
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
133
|
+
approved = if native_call
|
|
134
|
+
native_approval_decision(args, call_id) == true
|
|
135
|
+
else
|
|
136
|
+
@state.approval_coordinator.request_approval(
|
|
137
|
+
task: @state.task, turn: @state.turn, tool: real_name, args: args, actor: @state.actor
|
|
138
|
+
).to_s == "approved"
|
|
139
|
+
end
|
|
140
|
+
return { error: "rejected by operator" } unless approved
|
|
55
141
|
end
|
|
56
142
|
|
|
57
143
|
started = monotonic
|
|
58
|
-
|
|
144
|
+
Thread.current[TIMING_KEY] = nil
|
|
145
|
+
result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { invoke(args) } }
|
|
59
146
|
# the ONE seam every tool result passes on its way to the model.
|
|
60
147
|
# For a declared-evidence tool: reshape to the lean envelope, record the ids
|
|
61
148
|
# on the ledger, hoard the attachments. No evidence = the result passes
|
|
62
149
|
# through untouched (one nil-check — parity).
|
|
150
|
+
capture_shared_result(call_id, result)
|
|
63
151
|
result = process_evidence(result)
|
|
64
|
-
|
|
152
|
+
result = fence(result)
|
|
153
|
+
if side_effect?
|
|
154
|
+
halt = { "content" => result.content } if continuation && result.is_a?(ToolDefinition::Halt)
|
|
155
|
+
record_side_effect!(call_id, **(halt ? { halt: halt } : {}))
|
|
156
|
+
(continuation["halts"] ||= {})[call_id] = halt if halt
|
|
157
|
+
@skip_side_effects << call_id if native_call
|
|
158
|
+
end
|
|
65
159
|
trace(call_id, args, result, started)
|
|
66
160
|
result
|
|
67
161
|
rescue ToolTimeout
|
|
@@ -70,10 +164,17 @@ module Insika
|
|
|
70
164
|
err
|
|
71
165
|
end
|
|
72
166
|
|
|
73
|
-
private
|
|
74
|
-
|
|
75
167
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
76
168
|
|
|
169
|
+
def invoke(args)
|
|
170
|
+
tool = __getobj__
|
|
171
|
+
if tool.respond_to?(:parameters_schema)
|
|
172
|
+
tool.call(**args.transform_keys(&:to_sym), tool_call: @state.current_tool_call)
|
|
173
|
+
else
|
|
174
|
+
tool.call(args)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
77
178
|
# with parallel tool calls on, the turn's shared semaphore
|
|
78
179
|
# (TurnState#tool_gate, sized by `limits[:tool_concurrency]`) caps how many run
|
|
79
180
|
# at once. Wraps the REAL call ONLY — the approval wait and the skip check are
|
|
@@ -83,21 +184,120 @@ module Insika
|
|
|
83
184
|
# wall-clock the model waited. No gate (the default, serial) = straight through.
|
|
84
185
|
def with_gate(&)
|
|
85
186
|
gate = @state.respond_to?(:tool_gate) ? @state.tool_gate : nil
|
|
86
|
-
|
|
187
|
+
return yield unless gate
|
|
188
|
+
|
|
189
|
+
serial = @state.respond_to?(:side_effect_gate) ? @state.side_effect_gate : nil
|
|
190
|
+
return gate.acquire(&) unless side_effect? && serial
|
|
191
|
+
|
|
192
|
+
# Backends may read-modify-write. Serialize writes before taking a slot,
|
|
193
|
+
# so queued writes cannot keep independent reads from running.
|
|
194
|
+
serial.acquire { gate.acquire(&) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def provenance_block(args)
|
|
198
|
+
tool = __getobj__
|
|
199
|
+
requirement = tool.respond_to?(:requires_evidence) ? tool.requires_evidence : nil
|
|
200
|
+
return unless requirement
|
|
201
|
+
|
|
202
|
+
ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
|
|
203
|
+
known = ledger ? ledger.ids : []
|
|
204
|
+
optional = optional_params(tool)
|
|
205
|
+
requirement.fetch("params").each do |param|
|
|
206
|
+
value = args.key?(param) ? args[param] : args[param.to_sym]
|
|
207
|
+
# A parameter the schema marks optional and the model left out carries no
|
|
208
|
+
# id to ground — nothing to check (a REQUIRED one left out is still a
|
|
209
|
+
# block: the write would run without the id the gate exists for).
|
|
210
|
+
next if value.nil? && ledger && optional.include?(param)
|
|
211
|
+
|
|
212
|
+
values = value.is_a?(Array) ? value : [value]
|
|
213
|
+
values = [nil] if values.empty? && !ledger
|
|
214
|
+
values.each do |id|
|
|
215
|
+
next if ledger && known.include?(id.to_s)
|
|
216
|
+
|
|
217
|
+
return Blocked[{ "status" => "blocked", "gate" => "provenance", "param" => param,
|
|
218
|
+
"value" => id.to_s, "instruction" => PROVENANCE_INSTRUCTION }]
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The wrapped tool's top-level parameters NOT in the schema's `required`
|
|
225
|
+
# (DataDefinedTool exposes its definition's schema; a code tool exposes none
|
|
226
|
+
# -> every declared parameter is treated as required).
|
|
227
|
+
def optional_params(tool)
|
|
228
|
+
return [] unless tool.respond_to?(:parameters_schema) && (schema = tool.parameters_schema).is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
schema = Coercion.deep_stringify(schema)
|
|
231
|
+
|
|
232
|
+
(schema["properties"] || {}).keys.map(&:to_s) - Array(schema["required"]).map(&:to_s)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def confirmation_hold(args)
|
|
236
|
+
return unless customer_confirm?
|
|
237
|
+
|
|
238
|
+
store = @state.respond_to?(:pending_action_store) ? @state.pending_action_store : nil
|
|
239
|
+
task = @state.task
|
|
240
|
+
if store.nil? || task.nil? || task.session_id.nil?
|
|
241
|
+
# Fail loud, never run: a write that needs the customer's word and has
|
|
242
|
+
# nowhere to wait for it is a misconfiguration, not a pass.
|
|
243
|
+
missing = store.nil? ? "no PendingActionStore" : "no session"
|
|
244
|
+
raise Insika::Error, "tool '#{real_name}' needs the customer's confirmation but the turn has #{missing} to hold it in"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
id = PendingActionStore.confirmation_id(task.session_id, real_name)
|
|
248
|
+
store.create(id: id, task_id: task.id, session_id: task.session_id, turn: @state.turn,
|
|
249
|
+
tool: real_name, args: args || {}, kind: PendingActionStore::CUSTOMER)
|
|
250
|
+
@event_stream&.emit(Insika::Event.new(
|
|
251
|
+
type: :confirmation_requested,
|
|
252
|
+
data: { pending_id: id, tool: real_name, args: args },
|
|
253
|
+
meta: { task_id: task.id, session_id: task.session_id }
|
|
254
|
+
))
|
|
255
|
+
# Key order is what the model reads first: the verdict, then the instruction,
|
|
256
|
+
# then the echo of what it asked for — the echo alone reads like a success.
|
|
257
|
+
Held[{ "executed" => false, "status" => "pending_confirmation", "gate" => "confirmation",
|
|
258
|
+
"instruction" => CONFIRMATION_INSTRUCTION, "tool" => real_name, "args" => args,
|
|
259
|
+
"pending_id" => id }]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def customer_confirm?
|
|
263
|
+
profile = @state.respond_to?(:profile) ? @state.profile : nil
|
|
264
|
+
list = profile.respond_to?(:customer_confirm) ? profile.customer_confirm : nil
|
|
265
|
+
Array(list).include?(real_name)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def emit_blocked(result)
|
|
269
|
+
task = @state.task # nil on a one-shot turn, like `trace` already assumes
|
|
270
|
+
@event_stream&.emit(Insika::Event.new(
|
|
271
|
+
type: :tool_blocked,
|
|
272
|
+
data: { name: real_name, gate: result["gate"], param: result["param"] },
|
|
273
|
+
meta: task ? { task_id: task.id, session_id: task.session_id } : {}
|
|
274
|
+
))
|
|
87
275
|
end
|
|
88
276
|
|
|
89
277
|
# Records the call for debugging in the Studio (name + model args + result +
|
|
90
278
|
# ms), keyed by the SESSION. Masking/truncation is the ToolTraceStore's job;
|
|
91
279
|
# here we only collect. NEVER breaks the turn (trace is observability).
|
|
280
|
+
def capture_shared_result(call_id, result)
|
|
281
|
+
return unless @state.respond_to?(:shared_conversations) && @state.shared_conversations
|
|
282
|
+
|
|
283
|
+
@state.shared_conversations.record_tool_result(task: @state.task, call_id: call_id, result: result)
|
|
284
|
+
end
|
|
285
|
+
|
|
92
286
|
def trace(call_id, args, result, started)
|
|
287
|
+
capture_shared_result(call_id, result)
|
|
288
|
+
timing = Thread.current[TIMING_KEY] || {}
|
|
289
|
+
Thread.current[TIMING_KEY] = nil
|
|
290
|
+
ms = started ? ((monotonic - started) * 1000).round : nil
|
|
291
|
+
TurnTiming.current&.tool(real_name, ToolTraceStore.ok?(result), ms)
|
|
93
292
|
return unless @trace_recorder && @state.task&.session_id
|
|
94
293
|
|
|
95
294
|
@trace_recorder.record(
|
|
96
295
|
session_id: @state.task.session_id,
|
|
97
|
-
entry: { "turn" => @state.turn, "tool" => real_name, "call_id" => call_id.to_s,
|
|
296
|
+
entry: { "task_id" => @state.task.id.to_s, "turn" => @state.turn, "tool" => real_name, "call_id" => call_id.to_s,
|
|
98
297
|
"args" => args, "result" => result,
|
|
99
|
-
"
|
|
100
|
-
"
|
|
298
|
+
"gate" => result.is_a?(Blocked) || result.is_a?(Held) ? result["gate"] : nil,
|
|
299
|
+
"ms" => ms,
|
|
300
|
+
"at" => Time.now.utc.iso8601 }.merge(timing)
|
|
101
301
|
)
|
|
102
302
|
rescue StandardError
|
|
103
303
|
nil
|
|
@@ -114,8 +314,18 @@ module Insika
|
|
|
114
314
|
# Does the current tool require approval? (names come from the Resolution
|
|
115
315
|
# via state).
|
|
116
316
|
def approval_required?
|
|
117
|
-
@state.respond_to?(:requires_approval) &&
|
|
118
|
-
|
|
317
|
+
(@state.respond_to?(:requires_approval) && Array(@state.requires_approval).include?(real_name)) ||
|
|
318
|
+
(__getobj__.respond_to?(:requires_approval?) && __getobj__.requires_approval?)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def native_approvals?
|
|
322
|
+
@state.respond_to?(:native_approvals) && @state.native_approvals == true
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def native_approval_decision(args, call_id)
|
|
326
|
+
@state.approval_coordinator.approval_decision(
|
|
327
|
+
task: @state.task, turn: @state.turn, tool: real_name, args: args, call_id: call_id
|
|
328
|
+
)
|
|
119
329
|
end
|
|
120
330
|
|
|
121
331
|
# The call's correlation: the provider id (RubyLLM chat, via
|
|
@@ -135,11 +345,36 @@ module Insika
|
|
|
135
345
|
end
|
|
136
346
|
|
|
137
347
|
# Written BEFORE the tool result returns to the model.
|
|
138
|
-
def record_side_effect!(call_id)
|
|
348
|
+
def record_side_effect!(call_id, **metadata)
|
|
139
349
|
return if call_id.to_s.empty?
|
|
140
350
|
|
|
141
351
|
@checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
|
|
142
|
-
tool_call_id: call_id)
|
|
352
|
+
tool_call_id: call_id, **metadata)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# ---- fencing ----------------------------------------------
|
|
356
|
+
|
|
357
|
+
# After the evidence reshape (the lean envelope is already the shape the
|
|
358
|
+
# model reads): every String leaf sanitized, keys and non-strings untouched,
|
|
359
|
+
# each leaf capped at the platform's `fencing.max_chars`. Off = bytes
|
|
360
|
+
# identical to today. An error hash is engine-authored — never touched.
|
|
361
|
+
def fence(result)
|
|
362
|
+
return result unless Insika::Fence.enabled?(@state.profile)
|
|
363
|
+
return result if result.is_a?(Hash) && (result[:error] || result["error"])
|
|
364
|
+
|
|
365
|
+
max = (@state.respond_to?(:fence_max_chars) && @state.fence_max_chars) || Insika::Fence::DEFAULT_MAX_CHARS
|
|
366
|
+
return Insika::Fence.sanitize_value(result, max_chars: max) unless lean_evidence?(result)
|
|
367
|
+
|
|
368
|
+
# A lean evidence result: the LINES are third-party text, the IDS are keys.
|
|
369
|
+
# The ledger recorded the ids byte-exact and a presentation or a write joins
|
|
370
|
+
# on them, so NFKC must not touch them (a fullwidth digit in a SKU would
|
|
371
|
+
# stop matching the moment the model repeated it).
|
|
372
|
+
items = result["items"].map { |i| i.merge("line" => Insika::Fence.sanitize_text(i["line"].to_s, max_chars: max)) }
|
|
373
|
+
result.merge("items" => items)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def lean_evidence?(result)
|
|
377
|
+
evidence_spec && result.is_a?(Hash) && result["items"].is_a?(Array)
|
|
143
378
|
end
|
|
144
379
|
|
|
145
380
|
# ---- evidence ---------------------------------------------
|
|
@@ -204,6 +439,8 @@ module Insika
|
|
|
204
439
|
|
|
205
440
|
@state.evidence_attachments ||= []
|
|
206
441
|
@state.evidence_attachments.concat(attachments)
|
|
442
|
+
ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
|
|
443
|
+
ledger.record_cards(attachments) if ledger.respond_to?(:record_cards)
|
|
207
444
|
end
|
|
208
445
|
end
|
|
209
446
|
end
|
data/lib/insika/tool_manifest.rb
CHANGED
|
@@ -73,6 +73,7 @@ module Insika
|
|
|
73
73
|
"response" => binding[:response],
|
|
74
74
|
"secret_headers" => binding[:secret_headers],
|
|
75
75
|
"side_effect" => t["side_effect"],
|
|
76
|
+
"timeout" => t["timeout"],
|
|
76
77
|
"group" => t["group"] || defaults["group"], # per-group allowlist:
|
|
77
78
|
"tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
|
|
78
79
|
# Per-tool only: "this result ends the turn" is a property of THIS backend's
|
data/lib/insika/tool_store.rb
CHANGED
|
@@ -35,6 +35,9 @@ module Insika
|
|
|
35
35
|
# -> [String] names, lexicographic order.
|
|
36
36
|
def names = @cs.keys(SCOPE)
|
|
37
37
|
|
|
38
|
+
# Changes whenever any config is written, in any process (ConfigStore#version_token).
|
|
39
|
+
def version_token = @cs.version_token
|
|
40
|
+
|
|
38
41
|
# -> [Hash] MASKED definitions (for the UI).
|
|
39
42
|
def all
|
|
40
43
|
names.filter_map { |n| get(n) }
|
|
@@ -55,10 +58,13 @@ module Insika
|
|
|
55
58
|
raise Insika::ValidationError, "tool '#{name}' already exists" if create_only && existing
|
|
56
59
|
|
|
57
60
|
final = definition.to_h
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
# A presentation tool makes no request — nothing to reconcile.
|
|
62
|
+
if final["request"]
|
|
63
|
+
final["request"]["headers"] = reconcile_secret_headers(
|
|
64
|
+
final["request"]["headers"], definition.secret_headers,
|
|
65
|
+
existing&.dig("definition", "request", "headers")
|
|
66
|
+
)
|
|
67
|
+
end
|
|
62
68
|
|
|
63
69
|
rec = build_record(final, existing)
|
|
64
70
|
@cs.put(SCOPE, name, rec)
|
|
@@ -110,6 +116,8 @@ module Insika
|
|
|
110
116
|
secret = definition["secret_headers"] || []
|
|
111
117
|
return definition if secret.empty?
|
|
112
118
|
|
|
119
|
+
return definition unless definition["request"]
|
|
120
|
+
|
|
113
121
|
headers = (definition.dig("request", "headers") || {}).each_with_object({}) do |(k, v), acc|
|
|
114
122
|
acc[k] = secret.include?(k) ? SecretMasking.mask(v) : v
|
|
115
123
|
end
|
|
@@ -37,6 +37,11 @@ module Insika
|
|
|
37
37
|
nil
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Conventional tool error = Hash with key "error"/:error (everything else is ok).
|
|
41
|
+
def self.ok?(result)
|
|
42
|
+
!(result.is_a?(Hash) && (result.key?("error") || result.key?(:error)))
|
|
43
|
+
end
|
|
44
|
+
|
|
40
45
|
# -> [Hash] session entries in chronological order. [] if none.
|
|
41
46
|
def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
|
|
42
47
|
|
|
@@ -49,19 +54,21 @@ module Insika
|
|
|
49
54
|
e = stringify_keys(entry)
|
|
50
55
|
{
|
|
51
56
|
"turn" => e["turn"], "tool" => e["tool"].to_s, "call_id" => e["call_id"].to_s,
|
|
52
|
-
"ok" => ok?(e["result"]),
|
|
57
|
+
"ok" => self.class.ok?(e["result"]),
|
|
53
58
|
"args" => clip(mask(e["args"])), "result" => clip(mask(e["result"])),
|
|
54
59
|
"ms" => e["ms"], "at" => e["at"].to_s
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
}.tap do |trace|
|
|
61
|
+
trace["task_id"] = e["task_id"].to_s if e["task_id"]
|
|
62
|
+
trace["gate"] = e["gate"].to_s if e["gate"]
|
|
63
|
+
# Timing breakdown a data-tool reports (see DataDefinedTool#record_timing).
|
|
64
|
+
%w[pre_ms http_ms server_ms].each { |k| trace[k] = e[k] if e[k].is_a?(Integer) }
|
|
65
|
+
end
|
|
61
66
|
end
|
|
62
67
|
|
|
63
68
|
# Masks values of sensitive keys (recursive); the rest pass through intact.
|
|
64
|
-
def mask(obj)
|
|
69
|
+
def mask(obj) = self.class.mask(obj)
|
|
70
|
+
|
|
71
|
+
def self.mask(obj)
|
|
65
72
|
case obj
|
|
66
73
|
when Hash
|
|
67
74
|
obj.each_with_object({}) do |(k, v), acc|
|