insika 0.0.1 → 0.1.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 +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -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 +362 -0
- data/docs/SKILLS.md +98 -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 +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -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/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -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_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/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/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 +71 -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 +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -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 +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -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 +114 -0
- data/lib/insika/executor.rb +1680 -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/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 +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -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 +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -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/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 +87 -0
- data/lib/insika/safety/moderator.rb +86 -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/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -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 +113 -0
- data/lib/insika/skill_store.rb +79 -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 +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -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 +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -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/token_estimator.rb +16 -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_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 +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -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 +158 -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 +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- metadata +395 -8
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "json"
|
|
5
|
+
require "erb"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
module Tools
|
|
9
|
+
# DATA-DEFINED tool: one class, N instances parameterized by a
|
|
10
|
+
# ToolDefinition (the same pattern as A2ARemote). It makes an HTTP call described
|
|
11
|
+
# in config — no Ruby code per tool. Since it inherits RubyLLM::Tool (pulls in the gem),
|
|
12
|
+
# it is NOT required in lib/insika.rb; the overlay loads it lazily at registration
|
|
13
|
+
# (Step B). Phase 5, Step A.
|
|
14
|
+
#
|
|
15
|
+
# Contract preserved by duck-typing: it overrides name/description/parameters/
|
|
16
|
+
# execute; RubyLLM's params_schema derives from #parameters automatically.
|
|
17
|
+
# execute NEVER raises — an error (missing param, blocked egress, HTTP, parse)
|
|
18
|
+
# becomes `{ error: }` to the model, like the other tools.
|
|
19
|
+
class DataDefinedTool < RubyLLM::Tool
|
|
20
|
+
def initialize(definition:, http:, egress: Insika::EgressGuard, egress_options: {},
|
|
21
|
+
event_stream: nil, turn_context: {})
|
|
22
|
+
@definition = definition
|
|
23
|
+
@http = http
|
|
24
|
+
@egress = egress
|
|
25
|
+
@egress_options = egress_options
|
|
26
|
+
@event_stream = event_stream
|
|
27
|
+
@turn_context = symbolize_ctx(turn_context)
|
|
28
|
+
super()
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Turn context (Phase 6/D2/G3): the registry tool does NOT receive TurnState,
|
|
32
|
+
# so the Executor DEPOSITS the turn ids here, per-turn
|
|
33
|
+
# (chat/agent/tenant/store). They resolve {{ctx.*}} — SEPARATE from the model's
|
|
34
|
+
# {{param}} — to emit X-Chat-Id/X-Store-Id/X-Agent-Id. They come from the TURN, never from
|
|
35
|
+
# the model (R2). Reader for testing; the writer is the Executor's injection point.
|
|
36
|
+
attr_reader :turn_context
|
|
37
|
+
|
|
38
|
+
def turn_context=(ctx)
|
|
39
|
+
@turn_context = symbolize_ctx(ctx)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# name/description/parameters per INSTANCE (otherwise the model would see the name
|
|
43
|
+
# derived from the class for every data-tool).
|
|
44
|
+
def name = @definition.name
|
|
45
|
+
def description = @definition.description
|
|
46
|
+
|
|
47
|
+
# FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
|
|
48
|
+
# the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
|
|
49
|
+
# params_schema; parameters is just a fallback). Provider-agnostic (Phase 7/F6) and
|
|
50
|
+
# the only form that expresses nesting (object/array/enum). Phase 7, Step A.
|
|
51
|
+
def params_schema = @definition.parameters
|
|
52
|
+
|
|
53
|
+
# FLAT top-level view for discovery (tool_search calls #parameters on the resolved
|
|
54
|
+
# tool). The real nested schema goes through #params_schema above.
|
|
55
|
+
def parameters
|
|
56
|
+
@parameters ||= @definition.top_level_params.each_with_object({}) do |p, acc|
|
|
57
|
+
sym = p[:name].to_sym
|
|
58
|
+
acc[sym] = RubyLLM::Parameter.new(sym, type: p[:type], desc: p[:description], required: p[:required])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The args are checked against the tool's own JSON Schema BEFORE the request is
|
|
63
|
+
# built: a call the schema does not allow becomes `{ error: }` the model can act
|
|
64
|
+
# on, instead of a wrongly-shaped request that a backend answers 200 to.
|
|
65
|
+
def execute(**kwargs)
|
|
66
|
+
if (bad = Insika::SchemaGuard.violation(@definition.parameters, kwargs))
|
|
67
|
+
return { error: bad }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
req = build_request(kwargs)
|
|
71
|
+
reason = @egress.violation(req[:url], **@egress_options)
|
|
72
|
+
return { error: "destination blocked: #{reason}" } if reason
|
|
73
|
+
|
|
74
|
+
result = @http.request(**req)
|
|
75
|
+
emit(result[:status])
|
|
76
|
+
payload = extract(result)
|
|
77
|
+
# The RESPONSE says the turn is over (`halt_when`): the backend already
|
|
78
|
+
# answered the customer, so letting the model comment would deliver the
|
|
79
|
+
# message twice. RubyLLM's Tool::Halt ends its loop right here — no second
|
|
80
|
+
# provider call, and the decision is the engine's, not a request in a prompt.
|
|
81
|
+
# Only on a 2xx: an error body that happens to carry the value is a failure,
|
|
82
|
+
# and a failure must reach the model.
|
|
83
|
+
if http_ok?(result) && @definition.halt?(result[:body])
|
|
84
|
+
# `say` (optional) travels WITH the halt so the Executor can publish it when
|
|
85
|
+
# the model wrote no lead-in. Wrapped only when there is one, so every tool
|
|
86
|
+
# that declares no `say` keeps producing exactly the payload it always did.
|
|
87
|
+
say = @definition.halt_say(result[:body])
|
|
88
|
+
return RubyLLM::Tool::Halt.new(say ? Insika::ToolDefinition.wrap_halt(payload, say) : payload)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
payload
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
{ error: "HTTP call failed: #{e.message}" }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Interpolates the definition's templates with the model's args, escaping by
|
|
99
|
+
# context: url/query -> percent-encode; header -> strips CR/LF (anti-injection);
|
|
100
|
+
# body -> JSON escaping.
|
|
101
|
+
def build_request(kwargs)
|
|
102
|
+
r = @definition.request
|
|
103
|
+
url = interpolate(r[:url], kwargs, :url)
|
|
104
|
+
url = append_query(url, r[:query], kwargs)
|
|
105
|
+
headers = r[:headers].transform_values { |v| interpolate(v, kwargs, :header) }
|
|
106
|
+
body = r[:body] && interpolate(r[:body], kwargs, :body)
|
|
107
|
+
{ method: r[:method], url: url, headers: headers, body: body, timeout: @definition.timeout }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def append_query(url, query, kwargs)
|
|
111
|
+
return url if query.nil? || query.empty?
|
|
112
|
+
|
|
113
|
+
pairs = query.map { |k, v| "#{ERB::Util.url_encode(k)}=#{interpolate(v, kwargs, :query)}" }
|
|
114
|
+
url + (url.include?("?") ? "&" : "?") + pairs.join("&")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def interpolate(template, kwargs, mode)
|
|
118
|
+
template.to_s.gsub(Insika::ToolDefinition::PLACEHOLDER_RE) do
|
|
119
|
+
encode(resolve(Regexp.last_match(1), kwargs), mode)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# ctx.* -> TURN context (deposited by the Executor); the rest -> MODEL args
|
|
124
|
+
# (kwargs). The split is the D2/R2 trust boundary: the model does
|
|
125
|
+
# not choose which chat/store the tool accesses.
|
|
126
|
+
def resolve(name, kwargs)
|
|
127
|
+
prefix = Insika::ToolDefinition::CTX_PREFIX
|
|
128
|
+
if name.start_with?(prefix)
|
|
129
|
+
@turn_context[name.delete_prefix(prefix).to_sym]
|
|
130
|
+
else
|
|
131
|
+
kwargs[name.to_sym]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def symbolize_ctx(ctx)
|
|
136
|
+
(ctx || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def encode(value, mode)
|
|
140
|
+
case mode
|
|
141
|
+
when :url, :query then ERB::Util.url_encode(value.to_s)
|
|
142
|
+
when :header then value.to_s.gsub(/[\r\n]/, "")
|
|
143
|
+
when :body then value.is_a?(String) ? value.to_json[1..-2] : value.to_json
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def extract(result)
|
|
148
|
+
case @definition.response[:extract]
|
|
149
|
+
when "status" then { status: result[:status] }
|
|
150
|
+
when "body_raw" then http_ok?(result) ? result[:body] : http_error(result)
|
|
151
|
+
when "json_path" then extract_json(result)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def extract_json(result)
|
|
156
|
+
return http_error(result) unless http_ok?(result)
|
|
157
|
+
|
|
158
|
+
parsed = begin
|
|
159
|
+
JSON.parse(result[:body])
|
|
160
|
+
rescue JSON::ParserError
|
|
161
|
+
return { error: "response is not JSON" }
|
|
162
|
+
end
|
|
163
|
+
dig_path(parsed, @definition.response[:path])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def dig_path(obj, path)
|
|
167
|
+
path.split(".").reduce(obj) do |cur, seg|
|
|
168
|
+
return { error: "path '#{path}' not found in the response" } unless cur.is_a?(Hash) && cur.key?(seg)
|
|
169
|
+
|
|
170
|
+
cur[seg]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# 2xx only. A 3xx is NOT success: the HttpClient does not follow redirects
|
|
175
|
+
# (the EgressGuard cleared the authored URL, not the hop's destination), and
|
|
176
|
+
# servers send a 3xx with an empty body — so treating it as ok handed the
|
|
177
|
+
# model "" and it narrated a plausible outage. A moved API must read as an
|
|
178
|
+
# error naming its new URL, which is a definition to fix.
|
|
179
|
+
def http_ok?(result) = result[:status] >= 200 && result[:status] < 300
|
|
180
|
+
|
|
181
|
+
# A non-2xx is an ERROR — the backend said so, and the model has to know the call
|
|
182
|
+
# failed. But the body of a failure is often the backend TALKING: an envelope with
|
|
183
|
+
# a status and an instruction ("chat not found — ask the person to start over").
|
|
184
|
+
# Flattening it into a 200-char slice of a string threw that away exactly when the
|
|
185
|
+
# model needed it most, so a JSON body rides along parsed, under its own key.
|
|
186
|
+
# A non-JSON body (an HTML error page) stays truncated: it is noise, not a message.
|
|
187
|
+
ERROR_BODY_MAX = 2_000
|
|
188
|
+
|
|
189
|
+
def http_error(result)
|
|
190
|
+
if (300..399).cover?(result[:status]) && result[:location]
|
|
191
|
+
return { error: "HTTP #{result[:status]}: moved to #{result[:location]}" }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
raw = result[:body].to_s
|
|
195
|
+
parsed = parse_error_body(raw)
|
|
196
|
+
return { error: "HTTP #{result[:status]}", body: parsed } if parsed
|
|
197
|
+
|
|
198
|
+
{ error: "HTTP #{result[:status]}: #{raw[0, 200]}" }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# -> parsed JSON body worth forwarding | nil (not JSON, or too big to be a message).
|
|
202
|
+
def parse_error_body(raw)
|
|
203
|
+
return nil if raw.empty? || raw.bytesize > ERROR_BODY_MAX
|
|
204
|
+
|
|
205
|
+
parsed = JSON.parse(raw)
|
|
206
|
+
parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : nil
|
|
207
|
+
rescue JSON::ParserError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# No task correlation (registry tool does not receive TurnState) -> meta {}.
|
|
212
|
+
# Emits only name + status: NEVER body/headers (0 secret leakage, R2).
|
|
213
|
+
def emit(status)
|
|
214
|
+
@event_stream&.emit(Insika::Event.new(
|
|
215
|
+
type: :data_tool_call, data: { tool: @definition.name, status: status }, meta: {}
|
|
216
|
+
))
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Tools
|
|
7
|
+
# Level 2 of progressive disclosure: loads the full SKILL.md body
|
|
8
|
+
# on demand. Respects the agent's allowlist (the model does not load a
|
|
9
|
+
# skill that the policy did not expose).
|
|
10
|
+
#
|
|
11
|
+
# `require "ruby_llm"` stays in THIS file (it inherits from
|
|
12
|
+
# RubyLLM::Tool), which is why it does NOT enter lib/insika.rb: the Executor
|
|
13
|
+
# loads it lazily inside create_chat.
|
|
14
|
+
class LoadSkill < RubyLLM::Tool
|
|
15
|
+
description "Loads the complete instructions (SKILL.md) of a skill by name"
|
|
16
|
+
param :name, desc: "Exact skill name, as listed in <available_skills>"
|
|
17
|
+
|
|
18
|
+
# RubyLLM::Tool#name derives from self.class.name — for a nested class it produces
|
|
19
|
+
# "insika--tools--load_skill", not "load_skill" (which wire_callbacks/
|
|
20
|
+
# :skill_activated and SkillCatalog#format_for_prompt assume). Explicit
|
|
21
|
+
# override. Coexists with
|
|
22
|
+
# `param :name` (verified: the param is still present).
|
|
23
|
+
def name = "load_skill"
|
|
24
|
+
|
|
25
|
+
def initialize(catalog, allowed_names)
|
|
26
|
+
@catalog = catalog
|
|
27
|
+
@allowed = Array(allowed_names).map(&:to_s)
|
|
28
|
+
super()
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def execute(name:)
|
|
32
|
+
return { error: "skill '#{name}' not available for this agent" } unless @allowed.include?(name.to_s)
|
|
33
|
+
|
|
34
|
+
skill = @catalog.find(name)
|
|
35
|
+
return { error: "skill '#{name}' not found" } unless skill
|
|
36
|
+
|
|
37
|
+
skill.body
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Tools
|
|
7
|
+
# Write path of the cross-session memory: the agent stores a
|
|
8
|
+
# fact (key+value, durable upsert) or a note (value without key) on demand.
|
|
9
|
+
# Deterministic — NO model call.
|
|
10
|
+
# System builtin (like load_skill/tool_search): `require "ruby_llm"` stays
|
|
11
|
+
# in THIS file, loaded lazily by the Executor in create_chat.
|
|
12
|
+
class Remember < RubyLLM::Tool
|
|
13
|
+
description "Stores information to remember in future conversations. Use `key` " \
|
|
14
|
+
"for a durable key-value fact (overwrites the previous one); omit " \
|
|
15
|
+
"`key` for a free-form note."
|
|
16
|
+
param :value, desc: "The content to remember"
|
|
17
|
+
param :key, desc: "Fact key (e.g.: 'plan', 'name'); omit for a note", required: false
|
|
18
|
+
|
|
19
|
+
# otherwise RubyLLM derives "insika--tools--remember" from the class name.
|
|
20
|
+
def name = "remember"
|
|
21
|
+
|
|
22
|
+
def initialize(store, tenant, event_stream:, state:)
|
|
23
|
+
@store = store
|
|
24
|
+
@tenant = tenant
|
|
25
|
+
@event_stream = event_stream
|
|
26
|
+
@state = state
|
|
27
|
+
super()
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def execute(value:, key: nil)
|
|
31
|
+
if key.to_s.strip.empty?
|
|
32
|
+
note = @store.add_note(tenant: @tenant, text: value.to_s)
|
|
33
|
+
emit("note", note.id)
|
|
34
|
+
{ remembered: "note", id: note.id }
|
|
35
|
+
else
|
|
36
|
+
@store.put_fact(tenant: @tenant, key: key.to_s, value: value.to_s)
|
|
37
|
+
emit("fact", key.to_s)
|
|
38
|
+
{ remembered: "fact", key: key.to_s }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def emit(kind, ref)
|
|
45
|
+
@event_stream.emit(Insika::Event.new(
|
|
46
|
+
type: :memory_written,
|
|
47
|
+
data: { kind: kind, key: ref },
|
|
48
|
+
meta: { task_id: @state.task.id, session_id: @state.task.session_id }
|
|
49
|
+
))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require_relative "agent_enum"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Tools
|
|
8
|
+
# In-process delegation to a CHILD agent (RFC-0010, item 21) — the Flue
|
|
9
|
+
# `session.task()` primitive. A system tool (like remember/load_skill): wired
|
|
10
|
+
# by the ChatBuilder ONLY when `profile.subagents` is present, so `require
|
|
11
|
+
# "ruby_llm"` stays in this file (loaded lazily in create_chat). NOT enveloped
|
|
12
|
+
# (system tool) — in the synchronous mode the child lives in the parent's
|
|
13
|
+
# envelope and re-runs on the parent's resume (RFC-0010 §6).
|
|
14
|
+
#
|
|
15
|
+
# It holds no delegation logic itself: `execute` reads the parent TurnState
|
|
16
|
+
# (the subagents allowlist + resolved model for inheritance + depth) and hands
|
|
17
|
+
# off to `Executor#run_subagent`, which spawns the isolated child turn and
|
|
18
|
+
# returns its result. The child result = its text + the linked child session
|
|
19
|
+
# id (§4.3 R3).
|
|
20
|
+
class Subagent < RubyLLM::Tool
|
|
21
|
+
description "Delegates a self-contained task to a specialized child agent. " \
|
|
22
|
+
"The child runs in an ISOLATED context (it does not see this " \
|
|
23
|
+
"conversation) — pass everything it needs in `message`. By default " \
|
|
24
|
+
"it BLOCKS and returns the child's final answer. Set async:true to " \
|
|
25
|
+
"fire-and-forget a long task: it returns immediately and the child's " \
|
|
26
|
+
"result arrives later as a new message on this conversation."
|
|
27
|
+
param :agent, desc: "Id of the child agent to delegate to (must be one this agent may spawn)"
|
|
28
|
+
param :message, desc: "The self-contained task/prompt for the child agent"
|
|
29
|
+
param :async, type: :boolean, required: false,
|
|
30
|
+
desc: "true = dispatch and continue (result delivered later); default false = wait for the answer"
|
|
31
|
+
|
|
32
|
+
# otherwise RubyLLM derives "insika--tools--subagent" from the class name.
|
|
33
|
+
def name = "spawn_subagent"
|
|
34
|
+
|
|
35
|
+
def initialize(runner:, state:)
|
|
36
|
+
@runner = runner
|
|
37
|
+
@state = state
|
|
38
|
+
@allowed = Array(state.profile.subagents).map(&:to_s)
|
|
39
|
+
super()
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The parent's allowlist is per-TURN data, so it is named per instance: the
|
|
43
|
+
# ids go into the description AND as an `enum` on `agent`. Measured, not
|
|
44
|
+
# guessed — with only "must be one this agent may spawn" in the schema, a
|
|
45
|
+
# real provider answered "let me check which agents are available" and then
|
|
46
|
+
# did the work itself instead of delegating. A model cannot call what it
|
|
47
|
+
# cannot name.
|
|
48
|
+
def description
|
|
49
|
+
return super if @allowed.empty?
|
|
50
|
+
|
|
51
|
+
"#{super} Agents you may spawn: #{@allowed.join(', ')}."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def params_schema
|
|
55
|
+
@agent_enum_schema ||= Insika::Tools::AgentEnum.inject(super, @allowed, path: %i[agent])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The child result is returned to the model as the tool result. On error we
|
|
59
|
+
# return { error: } (never raise) — a bad `agent`/depth/child failure is a
|
|
60
|
+
# message to the model, not a turn-killer (parity with A2ARemote).
|
|
61
|
+
def execute(agent:, message:, async: false)
|
|
62
|
+
result = @runner.run_subagent(agent: agent.to_s, message: message.to_s,
|
|
63
|
+
parent_state: @state, async: async == true)
|
|
64
|
+
return { error: result[:error] } if result[:error]
|
|
65
|
+
|
|
66
|
+
# async dispatch: the ack (the child result arrives later as a new turn).
|
|
67
|
+
return { dispatched: true, agent: result[:agent], session_id: result[:session_id] } if result[:dispatched]
|
|
68
|
+
|
|
69
|
+
# sync: link the child session id alongside the text so a multi-step parent
|
|
70
|
+
# can reference it and the transcript stays auditable (§4.3 R3 / §7).
|
|
71
|
+
{ text: result[:text], session_id: result[:session_id] }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require_relative "agent_enum"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Tools
|
|
8
|
+
# PARALLEL fan-out of child agents (RFC-0010 §A): delegate N self-contained
|
|
9
|
+
# tasks at once and get all answers back together, in ONE parent turn. Sibling
|
|
10
|
+
# of `spawn_subagent` (single); this one always sync-joins — the children run
|
|
11
|
+
# concurrently (their provider waits overlap on the reactor) and the combined
|
|
12
|
+
# result comes back as this tool's result. A system tool wired by the ChatBuilder
|
|
13
|
+
# when `profile.subagents` is present; `require "ruby_llm"` stays in this file
|
|
14
|
+
# (loaded lazily in create_chat). NOT enveloped (children live in the parent's
|
|
15
|
+
# envelope, same as the sync single).
|
|
16
|
+
class Subagents < RubyLLM::Tool
|
|
17
|
+
description "Delegates SEVERAL self-contained tasks to child agents IN " \
|
|
18
|
+
"PARALLEL and returns all their answers together. Use this — not " \
|
|
19
|
+
"repeated spawn_subagent calls — when you have multiple independent " \
|
|
20
|
+
"subtasks whose results you'll combine: it runs them concurrently " \
|
|
21
|
+
"(much faster). Each child runs in an ISOLATED context, so put " \
|
|
22
|
+
"everything it needs in its `message`."
|
|
23
|
+
# array-of-objects param via the explicit JSON-schema form (the `param` DSL
|
|
24
|
+
# only reaches strings/scalars). Top-level `tasks` arrives as a kwarg to execute.
|
|
25
|
+
params(
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
tasks: {
|
|
29
|
+
type: "array",
|
|
30
|
+
description: "The independent subtasks to run in parallel",
|
|
31
|
+
items: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
agent: { type: "string", description: "id of the child agent (must be one this agent may spawn)" },
|
|
35
|
+
message: { type: "string", description: "the self-contained task/prompt for that child" }
|
|
36
|
+
},
|
|
37
|
+
required: %w[agent message]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
required: %w[tasks]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# otherwise RubyLLM derives "insika--tools--subagents" from the class name.
|
|
45
|
+
def name = "spawn_subagents"
|
|
46
|
+
|
|
47
|
+
def initialize(runner:, state:)
|
|
48
|
+
@runner = runner
|
|
49
|
+
@state = state
|
|
50
|
+
@allowed = Array(state.profile.subagents).map(&:to_s)
|
|
51
|
+
super()
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Same reason as `spawn_subagent`: the parent's allowlist is per-turn data,
|
|
55
|
+
# so the ids are named per instance — in the description and as an `enum` on
|
|
56
|
+
# each task's `agent`. See Tools::AgentEnum.
|
|
57
|
+
def description
|
|
58
|
+
return super if @allowed.empty?
|
|
59
|
+
|
|
60
|
+
"#{super} Agents you may spawn: #{@allowed.join(', ')}."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def params_schema
|
|
64
|
+
@agent_enum_schema ||= Insika::Tools::AgentEnum.inject(super, @allowed, path: %i[tasks agent])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# -> { results: [{agent:, text:, session_id:} | {agent:, error:}] } | { error: }.
|
|
68
|
+
# Never raises: a bad envelope / per-task failure is a message to the model.
|
|
69
|
+
def execute(tasks:)
|
|
70
|
+
result = @runner.run_subagents(tasks: Array(tasks), parent_state: @state)
|
|
71
|
+
return { error: result[:error] } if result[:error]
|
|
72
|
+
|
|
73
|
+
{ results: result[:results] }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Tools
|
|
7
|
+
# Level 2 of TOOLS progressive disclosure (analog of LoadSkill):
|
|
8
|
+
# searches the deferred catalog and PROMOTES the relevant ones into the live chat via
|
|
9
|
+
# chat.with_tools (verified to propagate on the next round of the same `ask`
|
|
10
|
+
# in ruby_llm 1.16). `require "ruby_llm"` stays in THIS file (it inherits from
|
|
11
|
+
# RubyLLM::Tool) — it does not enter lib/insika.rb; the Executor loads it lazily in
|
|
12
|
+
# configure_chat (like LoadSkill).
|
|
13
|
+
class ToolSearch < RubyLLM::Tool
|
|
14
|
+
description "Searches and enables additional tools by describing the need"
|
|
15
|
+
param :query, desc: "What you need to do (e.g.: 'send email', 'generate invoice')"
|
|
16
|
+
|
|
17
|
+
# RubyLLM::Tool#name derives from self.class.name — for a nested class
|
|
18
|
+
# (Insika::Tools::ToolSearch) it produces "insika--tools--tool_search", not
|
|
19
|
+
# "tool_search". Explicit override: the name the model calls must match
|
|
20
|
+
# the catalog/docs/tests.
|
|
21
|
+
def name = "tool_search"
|
|
22
|
+
|
|
23
|
+
def initialize(catalog, deferred_allowed, chat, tool_registry:, event_stream:,
|
|
24
|
+
checkpoint_store:, state:)
|
|
25
|
+
@catalog = catalog
|
|
26
|
+
@deferred_allowed = Array(deferred_allowed).map(&:to_s)
|
|
27
|
+
@chat = chat
|
|
28
|
+
@tool_registry = tool_registry
|
|
29
|
+
@event_stream = event_stream
|
|
30
|
+
@checkpoint_store = checkpoint_store
|
|
31
|
+
@state = state
|
|
32
|
+
@promoted = [] # names already promoted IN THIS chat — idempotency
|
|
33
|
+
super()
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def execute(query:)
|
|
37
|
+
matches = @catalog.search(query, within: @deferred_allowed)
|
|
38
|
+
emit_tool_search(query, matches.map(&:name))
|
|
39
|
+
if matches.empty?
|
|
40
|
+
return { matched: [], message: "no tool found for '#{query}'" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
new_matches = matches.reject { |m| @promoted.include?(m.name) }
|
|
44
|
+
promote(new_matches) unless new_matches.empty?
|
|
45
|
+
|
|
46
|
+
{ matched: matches.map { |m| describe(m) } }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Instantiates (via tool_registry), wraps in the SAME ToolEnvelope as the eager
|
|
52
|
+
# ones (profile timeout + state's skip_side_effects) and promotes via
|
|
53
|
+
# chat.with_tools. A NotFoundError (misaligned catalog) drops only
|
|
54
|
+
# that match — the search does not break.
|
|
55
|
+
def promote(entries)
|
|
56
|
+
timeout = @state.profile.limits[:tool_timeout] || 60
|
|
57
|
+
wrapped = entries.filter_map do |entry|
|
|
58
|
+
tool = @tool_registry.resolve(entry.name)
|
|
59
|
+
@promoted << entry.name
|
|
60
|
+
ToolEnvelope.new(tool, state: @state, checkpoint_store: @checkpoint_store,
|
|
61
|
+
tool_registry: @tool_registry, timeout: timeout,
|
|
62
|
+
skip_side_effects: Array(@state.skip_side_effects))
|
|
63
|
+
rescue Insika::NotFoundError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
@chat.with_tools(*wrapped) unless wrapped.empty?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Mirrors :skill_activated, but emitted by the tool itself (it has event_stream/
|
|
70
|
+
# state in the constructor). Without a monotonic `seq` (private to the Executor) — a
|
|
71
|
+
# documented gap, not a blocker.
|
|
72
|
+
def emit_tool_search(query, matched_names)
|
|
73
|
+
@event_stream.emit(Insika::Event.new(
|
|
74
|
+
type: :tool_search,
|
|
75
|
+
data: { query: query, matched: matched_names },
|
|
76
|
+
meta: { task_id: @state.task.id, session_id: @state.task.session_id }
|
|
77
|
+
))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def describe(entry)
|
|
81
|
+
tool = @tool_registry.resolve(entry.name)
|
|
82
|
+
{
|
|
83
|
+
name: entry.name,
|
|
84
|
+
description: entry.description,
|
|
85
|
+
parameters: tool.parameters.transform_values do |p|
|
|
86
|
+
{ type: p.type, description: p.description, required: p.required }
|
|
87
|
+
end
|
|
88
|
+
}
|
|
89
|
+
rescue Insika::NotFoundError
|
|
90
|
+
{ name: entry.name, description: entry.description, parameters: {} }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|