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,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Tool manifest: the BATCH, DATA-driven, industry-STANDARD
|
|
5
|
+
# (JSON Schema) form of the data-tools. A `defaults` (common binding:
|
|
6
|
+
# base_url/path_template/method/headers/secret_headers/response) + a list of
|
|
7
|
+
# `tools`. Each tool is normalized — inheriting the defaults, applying the envelope
|
|
8
|
+
# adapter (OpenAI/MCP/raw) and resolving `endpoint`→url + `{{secret.*}}`/
|
|
9
|
+
# `{{env.*}}` — into a ToolDefinition Hash consumable by the ToolStore.
|
|
10
|
+
#
|
|
11
|
+
# GENERIC: nothing here mentions a consumer/gateway — the manifest IS the contract. It
|
|
12
|
+
# only NORMALIZES/RESOLVES; the final validation (method/url/types, model placeholders
|
|
13
|
+
# `{{param}}`/turn `{{ctx.*}}`, safe subset of JSON Schema) belongs to
|
|
14
|
+
# ToolDefinition (single source). The batch upsert + hot reload belongs to the
|
|
15
|
+
# :import_tools Command (which injects the secret/env resolvers).
|
|
16
|
+
#
|
|
17
|
+
# Two placeholder classes by resolution MOMENT:
|
|
18
|
+
# - `{{param}}`/`{{ctx.*}}` : resolved AT THE TURN (by DataDefinedTool) —
|
|
19
|
+
# they stay INTACT here; ToolDefinition validates them.
|
|
20
|
+
# - `{{secret.*}}`/`{{env.*}}` : resolved AT INGESTION (here) — from the deployment,
|
|
21
|
+
# never from the manifest (R3). `{{env.*}}` is non-secret config (any
|
|
22
|
+
# template); `{{secret.*}}` is a credential and is ONLY allowed in a header
|
|
23
|
+
# declared in `secret_headers`, with value === `{{secret.X}}` (nothing literal,
|
|
24
|
+
# nothing outside a header — otherwise the secret would leak without masking).
|
|
25
|
+
#
|
|
26
|
+
# Interface envelope (what the model sees), by adapter:
|
|
27
|
+
# - `parameters` (raw JSON Schema), OR
|
|
28
|
+
# - `{ "function": { name, description, parameters } }` (OpenAI/Anthropic), OR
|
|
29
|
+
# - `{ "inputSchema": {...} }` (MCP).
|
|
30
|
+
ToolManifest = Data.define(:version, :defaults, :tools)
|
|
31
|
+
|
|
32
|
+
# Reopens the class (constants inside a Data.define block land in the wrong LEXICAL
|
|
33
|
+
# scope — same pattern as the sibling ToolDefinition).
|
|
34
|
+
class ToolManifest
|
|
35
|
+
ENV_PREFIX = "env."
|
|
36
|
+
SECRET_PREFIX = "secret."
|
|
37
|
+
private_constant :ENV_PREFIX, :SECRET_PREFIX
|
|
38
|
+
|
|
39
|
+
# Raw Hash (string|symbol keys) -> ToolManifest. Validates only the top-level
|
|
40
|
+
# STRUCTURE (version/defaults/tools); each tool's content is validated during
|
|
41
|
+
# normalization (per-tool, isolable by the Command — R4). Raises ValidationError.
|
|
42
|
+
def self.from_h(hash)
|
|
43
|
+
h = stringify(hash || {})
|
|
44
|
+
version = h.fetch("version", 1)
|
|
45
|
+
defaults = h.fetch("defaults", {}) || {}
|
|
46
|
+
tools = h.fetch("tools", []) || []
|
|
47
|
+
raise Insika::ValidationError, "manifest: 'defaults' must be an object" unless defaults.is_a?(Hash)
|
|
48
|
+
raise Insika::ValidationError, "manifest: 'tools' must be a list" unless tools.is_a?(Array)
|
|
49
|
+
|
|
50
|
+
new(version: version, defaults: defaults, tools: tools)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# -> [ { ToolDefinition hash } ] with inherited defaults, normalized envelopes,
|
|
54
|
+
# resolved endpoint→url and resolved secret/env. Raises on the FIRST malformed
|
|
55
|
+
# tool — use #definition_for per-tool to isolate partial failure (R4).
|
|
56
|
+
def tool_definitions(secrets:, env:)
|
|
57
|
+
tools.map { |t| definition_for(t, secrets: secrets, env: env) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Normalizes ONE raw tool -> ToolDefinition Hash. Raises ValidationError
|
|
61
|
+
# (invalid envelope, missing endpoint/url, secret/env not configured, R3).
|
|
62
|
+
def definition_for(raw_tool, secrets:, env:)
|
|
63
|
+
t = self.class.stringify(raw_tool || {})
|
|
64
|
+
raise Insika::ValidationError, "tool must be an object" unless t.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
interface = extract_interface(t)
|
|
67
|
+
binding = build_binding(t, secrets, env)
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
"name" => interface[:name], "description" => interface[:description],
|
|
71
|
+
"parameters" => interface[:parameters],
|
|
72
|
+
"request" => binding[:request],
|
|
73
|
+
"response" => binding[:response],
|
|
74
|
+
"secret_headers" => binding[:secret_headers],
|
|
75
|
+
"side_effect" => t["side_effect"],
|
|
76
|
+
"group" => t["group"] || defaults["group"], # per-group allowlist:
|
|
77
|
+
"tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
|
|
78
|
+
# Per-tool only: "this result ends the turn" is a property of THIS backend's
|
|
79
|
+
# response, never something a manifest default should hand to its siblings.
|
|
80
|
+
"halt_when" => t["halt_when"]
|
|
81
|
+
}.compact
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Envelope adapter: extracts the INTERFACE (name/description/parameters) from
|
|
87
|
+
# any standard form. name/description fall back to the tool's top level when the
|
|
88
|
+
# envelope does not carry them.
|
|
89
|
+
def extract_interface(t)
|
|
90
|
+
fn = t["function"]
|
|
91
|
+
if fn.is_a?(Hash) # OpenAI/Anthropic function tool
|
|
92
|
+
{ name: fn["name"] || t["name"], description: fn["description"] || t["description"],
|
|
93
|
+
parameters: fn["parameters"] }
|
|
94
|
+
elsif t.key?("inputSchema") # MCP tool
|
|
95
|
+
{ name: t["name"], description: t["description"], parameters: t["inputSchema"] }
|
|
96
|
+
else # raw: parameters is JSON Schema
|
|
97
|
+
{ name: t["name"], description: t["description"], parameters: t["parameters"] }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Builds the binding (request/response/secret_headers) inheriting defaults, with the
|
|
102
|
+
# tool winning. Resolves endpoint→url, secret (only in secret headers) and env.
|
|
103
|
+
def build_binding(t, secrets, env)
|
|
104
|
+
method = (t["method"] || defaults["method"] || "GET").to_s.upcase
|
|
105
|
+
headers = merge_maps(defaults["headers"], t["headers"])
|
|
106
|
+
query = merge_maps(defaults["query"], t["query"])
|
|
107
|
+
body = t.key?("body") ? t["body"] : defaults["body"]
|
|
108
|
+
secret_headers = (Array(defaults["secret_headers"]) | Array(t["secret_headers"])).map(&:to_s)
|
|
109
|
+
response = t["response"] || defaults["response"] || { "extract" => "body_raw" }
|
|
110
|
+
|
|
111
|
+
url = resolve_url(t, env)
|
|
112
|
+
headers = resolve_headers(headers, secret_headers, secrets, env)
|
|
113
|
+
query = query.transform_values { |v| resolve_env(v, env) }
|
|
114
|
+
body = body.nil? ? nil : resolve_env(body, env)
|
|
115
|
+
guard_no_stray_secrets!(url: url, headers: headers, secret_headers: secret_headers, query: query, body: body)
|
|
116
|
+
|
|
117
|
+
{ request: { "method" => method, "url" => url, "headers" => headers,
|
|
118
|
+
"query" => query, "body" => body }.compact,
|
|
119
|
+
response: response, secret_headers: secret_headers }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# explicit tool url OR base_url + path_template({endpoint}). The `endpoint` is
|
|
123
|
+
# DATA (never inferred from the name — R6): it resolves name↔slug remaps. `{endpoint}`
|
|
124
|
+
# is a manifest substitution (single key), distinct from the turn's `{{param}}`.
|
|
125
|
+
def resolve_url(t, env)
|
|
126
|
+
if self.class.presence(t["url"])
|
|
127
|
+
return resolve_env(t["url"], env)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
base = self.class.presence(defaults["base_url"]) ||
|
|
131
|
+
(raise Insika::ValidationError, "tool '#{t['name']}' has no url and manifest has no defaults.base_url")
|
|
132
|
+
path = self.class.presence(defaults["path_template"]) ||
|
|
133
|
+
(raise Insika::ValidationError, "tool '#{t['name']}' has no url and manifest has no defaults.path_template")
|
|
134
|
+
endpoint = self.class.presence(t["endpoint"]) ||
|
|
135
|
+
(raise Insika::ValidationError, "tool '#{t['name']}' has no 'endpoint' (never inferred from the name — R6)")
|
|
136
|
+
|
|
137
|
+
resolve_env(base.to_s + path.to_s.gsub("{endpoint}", endpoint.to_s), env)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Resolves headers: a SECRET header (name in secret_headers) MUST contain a
|
|
141
|
+
# {{secret.*}} reference (never literal — R3; a prefix like "Bearer " is ok) and
|
|
142
|
+
# resolves secret+env; the rest resolve only {{env.*}}.
|
|
143
|
+
def resolve_headers(headers, secret_headers, secrets, env)
|
|
144
|
+
headers.each_with_object({}) do |(name, value), acc|
|
|
145
|
+
acc[name] =
|
|
146
|
+
if secret_headers.include?(name)
|
|
147
|
+
resolve_secret_header(name, value, secrets, env)
|
|
148
|
+
else
|
|
149
|
+
resolve_env(value, env)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def resolve_secret_header(name, value, secrets, env)
|
|
155
|
+
unless value.to_s.match?(/\{\{\s*#{SECRET_PREFIX}/)
|
|
156
|
+
raise Insika::ValidationError,
|
|
157
|
+
"secret_header '#{name}' must reference {{secret.<name>}} (never a literal — R3)"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
substitute(value, secrets: secrets, env: env)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Substitutes {{env.X}} with the deployment value; leaves the other placeholders
|
|
164
|
+
# ({{param}}/{{ctx.*}}/{{secret.*}}) INTACT. Used on NON-secret fields — a
|
|
165
|
+
# remaining {{secret.*}} is blocked by #guard_no_stray_secrets! (it would leak).
|
|
166
|
+
def resolve_env(template, env)
|
|
167
|
+
substitute(template, secrets: nil, env: env)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Ingestion interpolator: always resolves {{env.X}}; resolves {{secret.X}} only
|
|
171
|
+
# when `secrets` was passed (secret fields). TURN placeholders
|
|
172
|
+
# ({{param}}/{{ctx.*}}) stay INTACT. Missing secret/env -> ValidationError.
|
|
173
|
+
def substitute(template, secrets:, env:)
|
|
174
|
+
template.to_s.gsub(Insika::ToolDefinition::PLACEHOLDER_RE) do
|
|
175
|
+
ref = Regexp.last_match(1)
|
|
176
|
+
if secrets && ref.start_with?(SECRET_PREFIX)
|
|
177
|
+
fetch!(secrets, ref.delete_prefix(SECRET_PREFIX), "secret")
|
|
178
|
+
elsif ref.start_with?(ENV_PREFIX)
|
|
179
|
+
fetch!(env, ref.delete_prefix(ENV_PREFIX), "env")
|
|
180
|
+
else
|
|
181
|
+
Regexp.last_match(0) # preserves {{param}}/{{ctx.*}} (and {{secret.*}} outside a secret header)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def fetch!(resolver, key, kind)
|
|
187
|
+
val = resolver[key]
|
|
188
|
+
raise Insika::ValidationError, "#{kind} '#{key}' not configured in the deployment" if blank?(val)
|
|
189
|
+
|
|
190
|
+
val.to_s
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A secret outside a secret-header would leak (it is not masked): reject {{secret.*}}
|
|
194
|
+
# in url/query/body/non-secret header after env resolution.
|
|
195
|
+
def guard_no_stray_secrets!(url:, headers:, secret_headers:, query:, body:)
|
|
196
|
+
non_secret = headers.reject { |name, _| secret_headers.include?(name) }.values
|
|
197
|
+
strings = [url, *non_secret, *query.values, body].compact
|
|
198
|
+
stray = strings.flat_map { |s| s.to_s.scan(Insika::ToolDefinition::PLACEHOLDER_RE).flatten }
|
|
199
|
+
.select { |r| r.start_with?(SECRET_PREFIX) }.uniq
|
|
200
|
+
return if stray.empty?
|
|
201
|
+
|
|
202
|
+
raise Insika::ValidationError,
|
|
203
|
+
"{{secret.*}} is only allowed in secret_headers (would leak without masking): #{stray.join(', ')}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def merge_maps(base, override)
|
|
207
|
+
(self.class.stringify(base || {})).merge(self.class.stringify(override || {}))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def blank?(v) = Insika::Coercion.blank?(v)
|
|
211
|
+
|
|
212
|
+
# Deep string keys (the wire may arrive symbolized; the nested JSON Schema
|
|
213
|
+
# must stay string-keyed — same as ToolDefinition's canonicalization).
|
|
214
|
+
def self.stringify(obj) = Insika::Coercion.deep_stringify(obj)
|
|
215
|
+
|
|
216
|
+
def self.presence(str) = Insika::Coercion.presence(str)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Mechanical (no-LLM) compression of tool output in the replayed transcript
|
|
7
|
+
# (A3/C3, hermes phase 1): identical tool results are stored ONCE — the first
|
|
8
|
+
# occurrence full — and every byte-identical repeat is replaced with a compact
|
|
9
|
+
# back-reference to it plus the original's first line. The cheap half of
|
|
10
|
+
# compaction: a search tool that returns the same catalog page over N turns
|
|
11
|
+
# currently pays for the full body N times, and that accumulation is what
|
|
12
|
+
# blows the budget first.
|
|
13
|
+
#
|
|
14
|
+
# Deliberately crude on purpose: SHA-256 digest, so ONLY exact matches dedupe —
|
|
15
|
+
# a differing detail is kept, never "close enough" dropped. Deterministic per
|
|
16
|
+
# input (the same transcript compresses identically every turn, prompt-cache
|
|
17
|
+
# friendly). Never touches user/assistant content and never mutates the input
|
|
18
|
+
# (symbol or string keys are preserved per message).
|
|
19
|
+
#
|
|
20
|
+
# Consumer: the history path of Context::Providers::Session, opt-in via
|
|
21
|
+
# AgentProfile#tool_output_compression. The LLM half of compaction (real
|
|
22
|
+
# summarization) remains F5 — decided with data, not by matrix.
|
|
23
|
+
class ToolOutputCompressor
|
|
24
|
+
# Below this a back-reference can never cost less than the body it
|
|
25
|
+
# replaces (an early bail; the REAL gate is the length check per repeat).
|
|
26
|
+
MIN_LENGTH = 200
|
|
27
|
+
# The mechanical "1-line summary": the original's first line, capped here
|
|
28
|
+
# (160 chars ≈ ~40 tokens — a bounded, informative stub).
|
|
29
|
+
SUMMARY_LIMIT = 160
|
|
30
|
+
|
|
31
|
+
# The boilerplate deliberately carries NO positional pointer ("see above"):
|
|
32
|
+
# the budget cut evicts the OLDEST unit first, which is the original this
|
|
33
|
+
# back-reference points at — a "↑" that survives its target leaves a dangle
|
|
34
|
+
# in the model's context (C3). The summary is the content; it stands alone.
|
|
35
|
+
BOILERPLATE = "[repeated tool output — byte-identical to an earlier result " \
|
|
36
|
+
"in this conversation; One-line summary: "
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# [message, ...] with repeated tool results back-referenced. A non-tool
|
|
40
|
+
# message or a tool result that is not a String passes through untouched.
|
|
41
|
+
# A repeat is replaced ONLY when the back-reference is strictly shorter
|
|
42
|
+
# than the content it would replace (in the 200–~260-char band a
|
|
43
|
+
# back-reference is LONGER than the original — replacing there would
|
|
44
|
+
# GROW the transcript, C3).
|
|
45
|
+
def compress_transcript(messages)
|
|
46
|
+
seen = {}
|
|
47
|
+
Array(messages).map do |m|
|
|
48
|
+
content = text_of(m)
|
|
49
|
+
next m unless content && content.length >= MIN_LENGTH
|
|
50
|
+
|
|
51
|
+
digest = Digest::SHA256.hexdigest(content)
|
|
52
|
+
if seen.key?(digest)
|
|
53
|
+
replacement = back_reference(seen[digest][:summary])
|
|
54
|
+
replacement.length < content.length ? replace_content(m, replacement) : m
|
|
55
|
+
else
|
|
56
|
+
seen[digest] = { summary: summarize(content) }
|
|
57
|
+
m
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# -> String | nil: the content of a role:tool message, nil otherwise
|
|
65
|
+
# (compression is for tool RESULTS only; a person's words are never touched).
|
|
66
|
+
def text_of(m)
|
|
67
|
+
return nil unless m.is_a?(Hash)
|
|
68
|
+
return nil unless (m[:role] || m["role"]).to_s == "tool"
|
|
69
|
+
|
|
70
|
+
c = m[:content] || m["content"]
|
|
71
|
+
c.is_a?(String) ? c : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def back_reference(summary)
|
|
75
|
+
"#{BOILERPLATE}#{summary}]"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A dup with the content replaced under the SAME key style as the original
|
|
79
|
+
# (symbol-keyed message -> symbol content, string-keyed -> string content).
|
|
80
|
+
def replace_content(m, text)
|
|
81
|
+
out = m.dup
|
|
82
|
+
if m.key?(:content)
|
|
83
|
+
out[:content] = text
|
|
84
|
+
else
|
|
85
|
+
out["content"] = text
|
|
86
|
+
end
|
|
87
|
+
out
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The mechanical one-line summary: the first line, whitespace-collapsed and
|
|
91
|
+
# capped. For a multi-line JSON tool body the first line is a real head; for
|
|
92
|
+
# a single-line blob it is a truncated head — never the whole body.
|
|
93
|
+
def summarize(content)
|
|
94
|
+
first = content.to_s.split(/\r?\n/).first.to_s
|
|
95
|
+
collapsed = first.gsub(/\s+/, " ").strip
|
|
96
|
+
collapsed.length <= SUMMARY_LIMIT ? collapsed : "#{collapsed[0, SUMMARY_LIMIT]}…"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Tool registry. Inherits from the generic one; `optional`/`side_effect`
|
|
5
|
+
# live in the metadata (defaults false). `side_effect: true` marks a
|
|
6
|
+
# non-idempotent tool — the checkpoint mechanism consumes it via `side_effect?`.
|
|
7
|
+
class ToolRegistry < Registry
|
|
8
|
+
# Legacy compat: `optional:`/`plugin:` are still accepted; `side_effect:` is
|
|
9
|
+
# new. Everything is normalized into the metadata (defaults false).
|
|
10
|
+
def register(name, callable = nil, optional: false, side_effect: false, plugin: nil, **extra, &block)
|
|
11
|
+
super(name, callable, plugin: plugin,
|
|
12
|
+
optional: optional, side_effect: side_effect, **extra, &block)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# -> bool; consumed by ToolEnvelope. Unknown tool = false.
|
|
16
|
+
def side_effect?(name)
|
|
17
|
+
entry = entries.find { |e| e.name == name.to_s }
|
|
18
|
+
entry ? !!entry.metadata[:side_effect] : false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# DATA-DEFINED tools authored at runtime. One record per tool in the ConfigStore
|
|
7
|
+
# (scope "tools"), keyed by name. It stores the whole ToolDefinition, versions it
|
|
8
|
+
# (like SkillStore/AgentFileStore) and MASKS the credential headers (just as
|
|
9
|
+
# McpStore masks the env): headers whose name is in `secret_headers` never
|
|
10
|
+
# leave in plaintext to the UI — they become the sentinel `__OCULTO__`. Only `get_raw`/
|
|
11
|
+
# `all_raw` (consumed by DataDefinedTool/overlay, never the screen) return the
|
|
12
|
+
# real values.,.
|
|
13
|
+
#
|
|
14
|
+
# Record in the ConfigStore:
|
|
15
|
+
# { "definition" => { ...ToolDefinition#to_h... },
|
|
16
|
+
# "updated_at" => iso8601,
|
|
17
|
+
# "history" => [ { "definition" =>, "at" => }, ... ] }
|
|
18
|
+
class ToolStore
|
|
19
|
+
SCOPE = "tools"
|
|
20
|
+
HISTORY_MAX = 20
|
|
21
|
+
|
|
22
|
+
def initialize(config_store:)
|
|
23
|
+
@cs = config_store
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# -> MASKED ToolDefinition Hash (secret headers with sentinel) | nil.
|
|
27
|
+
def get(name)
|
|
28
|
+
d = raw_definition(name)
|
|
29
|
+
d && mask_definition(d)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# -> ToolDefinition Hash with REAL headers | nil. Internal use (tool/overlay).
|
|
33
|
+
def get_raw(name) = raw_definition(name)
|
|
34
|
+
|
|
35
|
+
# -> [String] names, lexicographic order.
|
|
36
|
+
def names = @cs.keys(SCOPE)
|
|
37
|
+
|
|
38
|
+
# -> [Hash] MASKED definitions (for the UI).
|
|
39
|
+
def all
|
|
40
|
+
names.filter_map { |n| get(n) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# -> [Hash] definitions with REAL headers (for the overlay/registry). Never to the screen.
|
|
44
|
+
def all_raw
|
|
45
|
+
names.filter_map { |n| raw_definition(n) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Writes (upsert). `attrs` is a ToolDefinition Hash (the UI may send secret
|
|
49
|
+
# headers as sentinel: preserves what was stored). create_only refuses to overwrite.
|
|
50
|
+
# Validates via ToolDefinition.from_h. -> MASKED definition Hash.
|
|
51
|
+
def write(attrs, create_only: false)
|
|
52
|
+
definition = Insika::ToolDefinition.from_h(attrs) # validates (raises ValidationError)
|
|
53
|
+
name = definition.name
|
|
54
|
+
existing = @cs.get(SCOPE, name)
|
|
55
|
+
raise Insika::ValidationError, "tool '#{name}' already exists" if create_only && existing
|
|
56
|
+
|
|
57
|
+
final = definition.to_h
|
|
58
|
+
final["request"]["headers"] = reconcile_secret_headers(
|
|
59
|
+
final["request"]["headers"], definition.secret_headers,
|
|
60
|
+
existing&.dig("definition", "request", "headers")
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
rec = build_record(final, existing)
|
|
64
|
+
@cs.put(SCOPE, name, rec)
|
|
65
|
+
mask_definition(final)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# -> bool (did it exist?).
|
|
69
|
+
def delete(name) = @cs.delete(SCOPE, name.to_s)
|
|
70
|
+
|
|
71
|
+
# -> [ { "definition" => MASKED, "at" => } ] most recent first.
|
|
72
|
+
def versions(name)
|
|
73
|
+
(record(name)&.fetch("history", []) || []).map do |h|
|
|
74
|
+
{ "definition" => mask_definition(h["definition"]), "at" => h["at"] }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Restores version `index` as the current definition (new write, versions the current).
|
|
79
|
+
# -> MASKED definition Hash.
|
|
80
|
+
def restore(name, index)
|
|
81
|
+
rec = record(name)
|
|
82
|
+
raise Insika::NotFoundError, "tool '#{name}' not found" unless rec
|
|
83
|
+
|
|
84
|
+
hist = rec.fetch("history", [])
|
|
85
|
+
i = Integer(index)
|
|
86
|
+
raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
|
|
87
|
+
|
|
88
|
+
# The historical version stores REAL headers -> rewrite directly (bypasses the
|
|
89
|
+
# input masking; write only re-validates the structure).
|
|
90
|
+
write(hist[i]["definition"])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def record(name) = @cs.get(SCOPE, name.to_s)
|
|
96
|
+
|
|
97
|
+
def raw_definition(name) = record(name)&.fetch("definition", nil)
|
|
98
|
+
|
|
99
|
+
def build_record(definition, current)
|
|
100
|
+
history = current ? current.fetch("history", []) : []
|
|
101
|
+
if current
|
|
102
|
+
history = [{ "definition" => current["definition"], "at" => current["updated_at"] }] + history
|
|
103
|
+
history = history.first(HISTORY_MAX)
|
|
104
|
+
end
|
|
105
|
+
{ "definition" => definition, "updated_at" => Time.now.utc.iso8601, "history" => history }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Each header in secret_headers becomes a sentinel; the rest pass through intact.
|
|
109
|
+
def mask_definition(definition)
|
|
110
|
+
secret = definition["secret_headers"] || []
|
|
111
|
+
return definition if secret.empty?
|
|
112
|
+
|
|
113
|
+
headers = (definition.dig("request", "headers") || {}).each_with_object({}) do |(k, v), acc|
|
|
114
|
+
acc[k] = secret.include?(k) ? SecretMasking.mask(v) : v
|
|
115
|
+
end
|
|
116
|
+
definition.merge("request" => definition["request"].merge("headers" => headers))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Reconciles ONLY the secret headers against what was stored: sentinel preserves,
|
|
120
|
+
# a new string replaces, "" clears (removes the key). Non-secret pass through intact.
|
|
121
|
+
def reconcile_secret_headers(incoming, secret_names, existing)
|
|
122
|
+
old = existing || {}
|
|
123
|
+
secret_names.each_with_object(incoming.dup) do |hname, acc|
|
|
124
|
+
next unless acc.key?(hname)
|
|
125
|
+
|
|
126
|
+
value = SecretMasking.reconcile(acc[hname], old[hname])
|
|
127
|
+
if value.nil? || value.to_s.empty?
|
|
128
|
+
acc.delete(hname)
|
|
129
|
+
else
|
|
130
|
+
acc[hname] = value
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Per-session TOOL-CALL trace, for debugging in the Studio. One
|
|
7
|
+
# record per session in the raw backend (scope "tool_traces") — RUNTIME data, next
|
|
8
|
+
# to sessions/tasks, NOT config (hence the raw `store:`, like SessionStore,
|
|
9
|
+
# not the ConfigStore). A capped LIST of entries; ToolEnvelope writes one
|
|
10
|
+
# per call (name + model args + result + status + ms). Serves
|
|
11
|
+
# `/studio/sessions/:id`.
|
|
12
|
+
#
|
|
13
|
+
# SECURITY lives HERE (the store owns it): the screen is for the operator, but the
|
|
14
|
+
# args/response may carry PII/credentials. Every write goes through mask (values of
|
|
15
|
+
# sensitive keys become a sentinel) + clip (truncates large fields). The ephemeral
|
|
16
|
+
# `data_tool_call` event (name+status, 0 leakage) still goes to the live cards;
|
|
17
|
+
# THIS is the durable, richer record, behind the Studio login.
|
|
18
|
+
class ToolTraceStore
|
|
19
|
+
SCOPE = "tool_traces"
|
|
20
|
+
MAX_PER_SESSION = 200 # tail per session (avoids growing without bound)
|
|
21
|
+
MAX_FIELD_CHARS = 2_000 # cap per args/result field
|
|
22
|
+
SECRET_KEY_RE = /token|secret|authorization|password|passwd|api[-_]?key|bearer|cookie/i
|
|
23
|
+
|
|
24
|
+
def initialize(store:)
|
|
25
|
+
@store = store
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Writes a (sanitized) entry into the session. Missing session_id -> no-op.
|
|
29
|
+
# ToolEnvelope already guards, but so does this: the trace NEVER breaks the turn.
|
|
30
|
+
def record(session_id:, entry:)
|
|
31
|
+
sid = session_id.to_s
|
|
32
|
+
return if sid.empty?
|
|
33
|
+
|
|
34
|
+
list = (@store.get(SCOPE, sid) || []) + [sanitize(entry)]
|
|
35
|
+
@store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
|
|
36
|
+
rescue StandardError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# -> [Hash] session entries in chronological order. [] if none.
|
|
41
|
+
def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
|
|
42
|
+
|
|
43
|
+
# Discards a session's trace (cleanup). -> bool (did it exist?).
|
|
44
|
+
def clear(session_id) = @store.delete(SCOPE, session_id.to_s)
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def sanitize(entry)
|
|
49
|
+
e = stringify_keys(entry)
|
|
50
|
+
{
|
|
51
|
+
"turn" => e["turn"], "tool" => e["tool"].to_s, "call_id" => e["call_id"].to_s,
|
|
52
|
+
"ok" => ok?(e["result"]),
|
|
53
|
+
"args" => clip(mask(e["args"])), "result" => clip(mask(e["result"])),
|
|
54
|
+
"ms" => e["ms"], "at" => e["at"].to_s
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Conventional tool error = Hash with key "error"/:error (everything else is ok).
|
|
59
|
+
def ok?(result)
|
|
60
|
+
!(result.is_a?(Hash) && (result.key?("error") || result.key?(:error)))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Masks values of sensitive keys (recursive); the rest pass through intact.
|
|
64
|
+
def mask(obj)
|
|
65
|
+
case obj
|
|
66
|
+
when Hash
|
|
67
|
+
obj.each_with_object({}) do |(k, v), acc|
|
|
68
|
+
acc[k.to_s] = SECRET_KEY_RE.match?(k.to_s) ? SecretMasking::SENTINEL : mask(v)
|
|
69
|
+
end
|
|
70
|
+
when Array then obj.map { |v| mask(v) }
|
|
71
|
+
else obj
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -> Readable, capped String (JSON when structured). Truncates by CHARACTER
|
|
76
|
+
# (not byte — avoids cutting UTF-8 mid-sequence).
|
|
77
|
+
def clip(obj)
|
|
78
|
+
s = obj.is_a?(String) ? obj : safe_json(obj)
|
|
79
|
+
s.length > MAX_FIELD_CHARS ? "#{s[0, MAX_FIELD_CHARS]}…(truncado)" : s
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def safe_json(obj)
|
|
83
|
+
JSON.generate(obj)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
obj.inspect
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stringify_keys(hash)
|
|
89
|
+
(hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Tools
|
|
7
|
+
# Delegates to a remote A2A agent. A NORMAL Tool Registry tool — the
|
|
8
|
+
# agent's allowlist governs who may delegate. Lazy require of the gem: it does NOT
|
|
9
|
+
# enter lib/insika.rb; it is loaded in the registration block (wiring) on the 1st
|
|
10
|
+
# instance. One instance per remote agent (its own name/description).
|
|
11
|
+
class A2ARemote < RubyLLM::Tool
|
|
12
|
+
param :message, desc: "The message/task for the remote agent"
|
|
13
|
+
|
|
14
|
+
def initialize(client:, url:, tool_name:, description:, event_stream: nil)
|
|
15
|
+
@client = client
|
|
16
|
+
@url = url
|
|
17
|
+
@tool_name = tool_name
|
|
18
|
+
@description = description
|
|
19
|
+
@event_stream = event_stream
|
|
20
|
+
super()
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# name/description per INSTANCE (RubyLLM derives them from the class; we override —
|
|
24
|
+
# otherwise the model would see "insika--tools--a2a_remote" for every remote).
|
|
25
|
+
def name = @tool_name
|
|
26
|
+
def description = @description
|
|
27
|
+
|
|
28
|
+
def execute(message:)
|
|
29
|
+
result = @client.call(@url, message.to_s)
|
|
30
|
+
emit(result)
|
|
31
|
+
result[:error] ? { error: result[:error] } : result[:text]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# :a2a_call without task correlation: registry tools do not receive the
|
|
37
|
+
# TurnState -> meta {} (like the Builder's :provider_warning). The :tool_call
|
|
38
|
+
# from wire_callbacks already correlates the call itself.
|
|
39
|
+
def emit(result)
|
|
40
|
+
@event_stream&.emit(Insika::Event.new(
|
|
41
|
+
type: :a2a_call,
|
|
42
|
+
data: { agent: @tool_name, remote_task_id: result[:id], state: result[:state] },
|
|
43
|
+
meta: {}
|
|
44
|
+
))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Tools
|
|
5
|
+
# Names the parent's subagent allowlist INSIDE the delegation tools' schema.
|
|
6
|
+
#
|
|
7
|
+
# The allowlist is per-turn data (`profile.subagents`), while a RubyLLM tool's
|
|
8
|
+
# schema is class-level, so the enum has to be injected per instance. Without
|
|
9
|
+
# it the model is told only that `agent` "must be one this agent may spawn" and
|
|
10
|
+
# is left to guess the ids; with it, the valid values are part of the contract
|
|
11
|
+
# the provider sees.
|
|
12
|
+
#
|
|
13
|
+
# Tolerant by construction: JSON-schema hashes reach us with symbol keys (the
|
|
14
|
+
# class-level `params(...)` form) or string keys (anything that round-tripped
|
|
15
|
+
# through JSON), and a shape we do not recognize is returned UNTOUCHED — a
|
|
16
|
+
# schema we failed to annotate still works, one we corrupted would break every
|
|
17
|
+
# delegation.
|
|
18
|
+
module AgentEnum
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# schema: the tool's JSON schema · allowed: [String] · path: the property
|
|
22
|
+
# chain leading to the `agent` property, e.g. %i[agent] or %i[tasks agent].
|
|
23
|
+
# -> a COPY with `enum` set, or the original when there is nothing to do.
|
|
24
|
+
def inject(schema, allowed, path:)
|
|
25
|
+
return schema if schema.nil? || Array(allowed).empty?
|
|
26
|
+
|
|
27
|
+
copy = deep_dup(schema)
|
|
28
|
+
target = resolve(copy, path)
|
|
29
|
+
return schema if target.nil?
|
|
30
|
+
|
|
31
|
+
target[key_for(target, :enum)] = Array(allowed).map(&:to_s)
|
|
32
|
+
copy
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Walks `properties` (and `items` for an array) down to the named property.
|
|
36
|
+
def resolve(node, path)
|
|
37
|
+
path.reduce(node) do |current, segment|
|
|
38
|
+
props = fetch(current, :properties)
|
|
39
|
+
props = fetch(fetch(current, :items) || {}, :properties) if props.nil?
|
|
40
|
+
prop = props && (props[segment] || props[segment.to_s])
|
|
41
|
+
return nil unless prop.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
prop
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fetch(node, key)
|
|
48
|
+
return nil unless node.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
node[key] || node[key.to_s]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Keep the hash's own key convention instead of mixing symbols into a
|
|
54
|
+
# string-keyed schema.
|
|
55
|
+
def key_for(hash, key)
|
|
56
|
+
hash.keys.any? { |k| k.is_a?(String) } ? key.to_s : key
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def deep_dup(value)
|
|
60
|
+
case value
|
|
61
|
+
when Hash then value.each_with_object({}) { |(k, v), acc| acc[k] = deep_dup(v) }
|
|
62
|
+
when Array then value.map { |v| deep_dup(v) }
|
|
63
|
+
else value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|