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,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/semaphore"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Tools
|
|
8
|
+
# Fan-out INSIDE a single tool (§): when a tool must gather N
|
|
9
|
+
# independent I/O calls (stock + price + promo + delivery) into ONE result,
|
|
10
|
+
# `gather` runs them CONCURRENTLY on the turn's reactor and returns their values
|
|
11
|
+
# IN ORDER. Because a data-tool spends its time on the HTTP wait, those waits
|
|
12
|
+
# overlap → wall-clock ≈ the slowest call, not the sum.
|
|
13
|
+
#
|
|
14
|
+
# This is the "aggregator tool" pattern: the model makes ONE tool call and the
|
|
15
|
+
# concurrency is an implementation detail of that tool — no change to the agent
|
|
16
|
+
# loop, no dependency on parallel tool execution. Use it in a custom
|
|
17
|
+
# Ruby tool's #execute:
|
|
18
|
+
#
|
|
19
|
+
# def execute(store_id:)
|
|
20
|
+
# stock, price, promo = Insika::Tools::Concurrency.gather(
|
|
21
|
+
# -> { fetch_stock(store_id) },
|
|
22
|
+
# -> { fetch_price(store_id) },
|
|
23
|
+
# -> { fetch_promo(store_id) }
|
|
24
|
+
# )
|
|
25
|
+
# { stock:, price:, promo: }
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# Bounded by `max` concurrent (default 8) to respect upstream rate limits. Runs
|
|
29
|
+
# correctly whether or not there is already a reactor (a tool runs inside the
|
|
30
|
+
# turn's Async fiber; `Sync` reuses it, and starts one otherwise for tests).
|
|
31
|
+
module Concurrency
|
|
32
|
+
DEFAULT_MAX = 8
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# blocks: callables (procs/lambdas) or a block yielding an index. Returns an
|
|
37
|
+
# Array of results aligned to the input order. An exception in any block
|
|
38
|
+
# propagates (the caller decides how to degrade — this helper does not swallow).
|
|
39
|
+
def gather(*blocks, max: DEFAULT_MAX)
|
|
40
|
+
blocks = blocks.flatten
|
|
41
|
+
return [] if blocks.empty?
|
|
42
|
+
|
|
43
|
+
results = Array.new(blocks.size)
|
|
44
|
+
Sync do
|
|
45
|
+
semaphore = Async::Semaphore.new([max, blocks.size].min)
|
|
46
|
+
blocks.each_with_index.map do |blk, i|
|
|
47
|
+
semaphore.async { results[i] = blk.call }
|
|
48
|
+
end.each(&:wait)
|
|
49
|
+
end
|
|
50
|
+
results
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
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
|
+
#
|
|
14
|
+
# Contract preserved by duck-typing: it overrides name/description/parameters/
|
|
15
|
+
# execute; RubyLLM's params_schema derives from #parameters automatically.
|
|
16
|
+
# execute NEVER raises — an error (missing param, blocked egress, HTTP, parse)
|
|
17
|
+
# becomes `{ error: }` to the model, like the other tools.
|
|
18
|
+
class DataDefinedTool < RubyLLM::Tool
|
|
19
|
+
def initialize(definition:, http:, egress: Insika::EgressGuard, egress_options: {},
|
|
20
|
+
event_stream: nil, turn_context: {})
|
|
21
|
+
@definition = definition
|
|
22
|
+
@http = http
|
|
23
|
+
@egress = egress
|
|
24
|
+
@egress_options = egress_options
|
|
25
|
+
@event_stream = event_stream
|
|
26
|
+
@turn_context = symbolize_ctx(turn_context)
|
|
27
|
+
super()
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Turn context: the registry tool does NOT receive TurnState,
|
|
31
|
+
# so the Executor DEPOSITS the turn ids here, per-turn
|
|
32
|
+
# (chat/agent/tenant/store). They resolve {{ctx.*}} — SEPARATE from the model's
|
|
33
|
+
# {{param}} — to emit X-Chat-Id/X-Store-Id/X-Agent-Id. They come from the TURN, never from
|
|
34
|
+
# the model (R2). Reader for testing; the writer is the Executor's injection point.
|
|
35
|
+
attr_reader :turn_context
|
|
36
|
+
|
|
37
|
+
def turn_context=(ctx)
|
|
38
|
+
@turn_context = symbolize_ctx(ctx)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# name/description/parameters per INSTANCE (otherwise the model would see the name
|
|
42
|
+
# derived from the class for every data-tool).
|
|
43
|
+
def name = @definition.name
|
|
44
|
+
def description = @definition.description
|
|
45
|
+
|
|
46
|
+
# FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
|
|
47
|
+
# the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
|
|
48
|
+
# params_schema; parameters is just a fallback). Provider-agnostic and
|
|
49
|
+
# the only form that expresses nesting (object/array/enum).,.
|
|
50
|
+
def params_schema = @definition.parameters
|
|
51
|
+
|
|
52
|
+
# FLAT top-level view for discovery (tool_search calls #parameters on the resolved
|
|
53
|
+
# tool). The real nested schema goes through #params_schema above.
|
|
54
|
+
def parameters
|
|
55
|
+
@parameters ||= @definition.top_level_params.each_with_object({}) do |p, acc|
|
|
56
|
+
sym = p[:name].to_sym
|
|
57
|
+
acc[sym] = RubyLLM::Parameter.new(sym, type: p[:type], desc: p[:description], required: p[:required])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The args are checked against the tool's own JSON Schema BEFORE the request is
|
|
62
|
+
# built: a call the schema does not allow becomes `{ error: }` the model can act
|
|
63
|
+
# on, instead of a wrongly-shaped request that a backend answers 200 to.
|
|
64
|
+
def execute(**kwargs)
|
|
65
|
+
if (bad = Insika::SchemaGuard.violation(@definition.parameters, kwargs))
|
|
66
|
+
return { error: bad }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
req = build_request(kwargs)
|
|
70
|
+
reason = @egress.violation(req[:url], **@egress_options)
|
|
71
|
+
return { error: "destination blocked: #{reason}" } if reason
|
|
72
|
+
|
|
73
|
+
result = @http.request(**req)
|
|
74
|
+
emit(result[:status])
|
|
75
|
+
payload = extract(result)
|
|
76
|
+
# The RESPONSE says the turn is over (`halt_when`): the backend already
|
|
77
|
+
# answered the customer, so letting the model comment would deliver the
|
|
78
|
+
# message twice. RubyLLM's Tool::Halt ends its loop right here — no second
|
|
79
|
+
# provider call, and the decision is the engine's, not a request in a prompt.
|
|
80
|
+
# Only on a 2xx: an error body that happens to carry the value is a failure,
|
|
81
|
+
# and a failure must reach the model.
|
|
82
|
+
if http_ok?(result) && @definition.halt?(result[:body])
|
|
83
|
+
# `say` (optional) travels WITH the halt so the Executor can publish it when
|
|
84
|
+
# the model wrote no lead-in. Wrapped only when there is one, so every tool
|
|
85
|
+
# that declares no `say` keeps producing exactly the payload it always did.
|
|
86
|
+
say = @definition.halt_say(result[:body])
|
|
87
|
+
return RubyLLM::Tool::Halt.new(say ? Insika::ToolDefinition.wrap_halt(payload, say) : payload)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
payload
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
{ error: "HTTP call failed: #{e.message}" }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Interpolates the definition's templates with the model's args, escaping by
|
|
98
|
+
# context: url/query -> percent-encode; header -> strips CR/LF (anti-injection);
|
|
99
|
+
# body -> JSON escaping.
|
|
100
|
+
def build_request(kwargs)
|
|
101
|
+
r = @definition.request
|
|
102
|
+
url = interpolate(r[:url], kwargs, :url)
|
|
103
|
+
url = append_query(url, r[:query], kwargs)
|
|
104
|
+
headers = r[:headers].transform_values { |v| interpolate(v, kwargs, :header) }
|
|
105
|
+
body = r[:body] && interpolate(r[:body], kwargs, :body)
|
|
106
|
+
{ method: r[:method], url: url, headers: headers, body: body, timeout: @definition.timeout }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def append_query(url, query, kwargs)
|
|
110
|
+
return url if query.nil? || query.empty?
|
|
111
|
+
|
|
112
|
+
pairs = query.map { |k, v| "#{ERB::Util.url_encode(k)}=#{interpolate(v, kwargs, :query)}" }
|
|
113
|
+
url + (url.include?("?") ? "&" : "?") + pairs.join("&")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def interpolate(template, kwargs, mode)
|
|
117
|
+
template.to_s.gsub(Insika::ToolDefinition::PLACEHOLDER_RE) do
|
|
118
|
+
encode(resolve(Regexp.last_match(1), kwargs), mode)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ctx.* -> TURN context (deposited by the Executor); the rest -> MODEL args
|
|
123
|
+
# (kwargs). The split is the/R2 trust boundary: the model does
|
|
124
|
+
# not choose which chat/store the tool accesses.
|
|
125
|
+
def resolve(name, kwargs)
|
|
126
|
+
prefix = Insika::ToolDefinition::CTX_PREFIX
|
|
127
|
+
if name.start_with?(prefix)
|
|
128
|
+
@turn_context[name.delete_prefix(prefix).to_sym]
|
|
129
|
+
else
|
|
130
|
+
kwargs[name.to_sym]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def symbolize_ctx(ctx)
|
|
135
|
+
(ctx || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def encode(value, mode)
|
|
139
|
+
case mode
|
|
140
|
+
when :url, :query then ERB::Util.url_encode(value.to_s)
|
|
141
|
+
when :header then value.to_s.gsub(/[\r\n]/, "")
|
|
142
|
+
when :body then value.is_a?(String) ? value.to_json[1..-2] : value.to_json
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def extract(result)
|
|
147
|
+
case @definition.response[:extract]
|
|
148
|
+
when "status" then { status: result[:status] }
|
|
149
|
+
when "body_raw" then http_ok?(result) ? result[:body] : http_error(result)
|
|
150
|
+
when "json_path" then extract_json(result)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def extract_json(result)
|
|
155
|
+
return http_error(result) unless http_ok?(result)
|
|
156
|
+
|
|
157
|
+
parsed = begin
|
|
158
|
+
JSON.parse(result[:body])
|
|
159
|
+
rescue JSON::ParserError
|
|
160
|
+
return { error: "response is not JSON" }
|
|
161
|
+
end
|
|
162
|
+
dig_path(parsed, @definition.response[:path])
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def dig_path(obj, path)
|
|
166
|
+
path.split(".").reduce(obj) do |cur, seg|
|
|
167
|
+
return { error: "path '#{path}' not found in the response" } unless cur.is_a?(Hash) && cur.key?(seg)
|
|
168
|
+
|
|
169
|
+
cur[seg]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# 2xx only. A 3xx is NOT success: the HttpClient does not follow redirects
|
|
174
|
+
# (the EgressGuard cleared the authored URL, not the hop's destination), and
|
|
175
|
+
# servers send a 3xx with an empty body — so treating it as ok handed the
|
|
176
|
+
# model "" and it narrated a plausible outage. A moved API must read as an
|
|
177
|
+
# error naming its new URL, which is a definition to fix.
|
|
178
|
+
def http_ok?(result) = result[:status] >= 200 && result[:status] < 300
|
|
179
|
+
|
|
180
|
+
# A non-2xx is an ERROR — the backend said so, and the model has to know the call
|
|
181
|
+
# failed. But the body of a failure is often the backend TALKING: an envelope with
|
|
182
|
+
# a status and an instruction ("chat not found — ask the person to start over").
|
|
183
|
+
# Flattening it into a 200-char slice of a string threw that away exactly when the
|
|
184
|
+
# model needed it most, so a JSON body rides along parsed, under its own key.
|
|
185
|
+
# A non-JSON body (an HTML error page) stays truncated: it is noise, not a message.
|
|
186
|
+
ERROR_BODY_MAX = 2_000
|
|
187
|
+
|
|
188
|
+
def http_error(result)
|
|
189
|
+
if (300..399).cover?(result[:status]) && result[:location]
|
|
190
|
+
return { error: "HTTP #{result[:status]}: moved to #{result[:location]}" }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
raw = result[:body].to_s
|
|
194
|
+
parsed = parse_error_body(raw)
|
|
195
|
+
return { error: "HTTP #{result[:status]}", body: parsed } if parsed
|
|
196
|
+
|
|
197
|
+
{ error: "HTTP #{result[:status]}: #{raw[0, 200]}" }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# -> parsed JSON body worth forwarding | nil (not JSON, or too big to be a message).
|
|
201
|
+
def parse_error_body(raw)
|
|
202
|
+
return nil if raw.empty? || raw.bytesize > ERROR_BODY_MAX
|
|
203
|
+
|
|
204
|
+
parsed = JSON.parse(raw)
|
|
205
|
+
parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : nil
|
|
206
|
+
rescue JSON::ParserError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# No task correlation (registry tool does not receive TurnState) -> meta {}.
|
|
211
|
+
# Emits only name + status: NEVER body/headers (0 secret leakage, R2).
|
|
212
|
+
def emit(status)
|
|
213
|
+
@event_stream&.emit(Insika::Event.new(
|
|
214
|
+
type: :data_tool_call, data: { tool: @definition.name, status: status }, meta: {}
|
|
215
|
+
))
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Tools
|
|
8
|
+
# Level 2 of progressive disclosure: loads the full SKILL.md body
|
|
9
|
+
# on demand. Respects the agent's allowlist (the model does not load a
|
|
10
|
+
# skill that the policy did not expose).
|
|
11
|
+
#
|
|
12
|
+
# `require "ruby_llm"` stays in THIS file (it inherits from
|
|
13
|
+
# RubyLLM::Tool), which is why it does NOT enter lib/insika.rb: the Executor
|
|
14
|
+
# loads it lazily inside create_chat.
|
|
15
|
+
class LoadSkill < RubyLLM::Tool
|
|
16
|
+
description "Loads the complete instructions (SKILL.md) of a skill by name"
|
|
17
|
+
param :name, desc: "Exact skill name, as listed in <available_skills>"
|
|
18
|
+
|
|
19
|
+
# RubyLLM::Tool#name derives from self.class.name — for a nested class it produces
|
|
20
|
+
# "insika--tools--load_skill", not "load_skill" (which wire_callbacks/
|
|
21
|
+
# :skill_activated and SkillCatalog#format_for_prompt assume). Explicit
|
|
22
|
+
# override. Coexists with
|
|
23
|
+
# `param :name` (verified: the param is still present).
|
|
24
|
+
def name = "load_skill"
|
|
25
|
+
|
|
26
|
+
# trace_recorder/state are OPTIONAL (nil = no trace, parity): this tool is
|
|
27
|
+
# deliberately NOT enveloped (ToolAssembly#wrap_tools), and the envelope is
|
|
28
|
+
# what records the tool trace — so without recording HERE, the one call an
|
|
29
|
+
# operator most needs to audit is the only one missing from the Studio's
|
|
30
|
+
# trace. Same shape as ToolSearch, which also emits its own event.
|
|
31
|
+
# `agent` selects WHICH body a name resolves to: an agent that specialized a
|
|
32
|
+
# shared skill must be served its own version, under the same bare name. nil =
|
|
33
|
+
# the shared scope only (parity).
|
|
34
|
+
def initialize(catalog, allowed_names, trace_recorder: nil, state: nil, agent: nil)
|
|
35
|
+
@catalog = catalog
|
|
36
|
+
@allowed = Array(allowed_names).map(&:to_s)
|
|
37
|
+
@trace_recorder = trace_recorder
|
|
38
|
+
@state = state
|
|
39
|
+
@agent = agent
|
|
40
|
+
super()
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def execute(name:)
|
|
44
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
45
|
+
result = load(name)
|
|
46
|
+
trace(name, result, started)
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def load(name)
|
|
53
|
+
return { error: "skill '#{name}' not available for this agent" } unless @allowed.include?(name.to_s)
|
|
54
|
+
|
|
55
|
+
skill = @catalog.find(name, agent: @agent)
|
|
56
|
+
return { error: "skill '#{name}' not found" } unless skill
|
|
57
|
+
|
|
58
|
+
with_companions(skill)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A skill's declared `companions:` come back in the SAME call, so the model
|
|
62
|
+
# cannot end up holding half a recipe (the reference table without the procedure
|
|
63
|
+
# that reads it — measured on a real pack, and the searches came out malformed).
|
|
64
|
+
#
|
|
65
|
+
# A lone skill returns its bare body, byte for byte as before: only a skill that
|
|
66
|
+
# actually declares companions pays the wrapper. Restricted to `@allowed`, which
|
|
67
|
+
# is the LAZY allowed set — a companion that is eager is already in the prompt in
|
|
68
|
+
# full, so fetching it again would only buy a duplicate.
|
|
69
|
+
def with_companions(skill)
|
|
70
|
+
extras = Array(skill.companions).filter_map do |name|
|
|
71
|
+
next unless @allowed.include?(name.to_s) && name.to_s != skill.name
|
|
72
|
+
|
|
73
|
+
@catalog.find(name, agent: @agent)
|
|
74
|
+
end
|
|
75
|
+
return skill.body if extras.empty?
|
|
76
|
+
|
|
77
|
+
([skill] + extras).uniq(&:name)
|
|
78
|
+
.map { |s| %(<skill name="#{s.name}">\n#{s.body}\n</skill>) }.join("\n\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Mirrors ToolEnvelope#trace (same entry shape, so the Studio renders it
|
|
82
|
+
# like any other call). Clipping/masking is the ToolTraceStore's job.
|
|
83
|
+
# NEVER breaks the turn — the trace is observability.
|
|
84
|
+
def trace(name, result, started)
|
|
85
|
+
return unless @trace_recorder && @state&.task&.session_id
|
|
86
|
+
|
|
87
|
+
@trace_recorder.record(
|
|
88
|
+
session_id: @state.task.session_id,
|
|
89
|
+
entry: { "turn" => @state.turn, "tool" => "load_skill", "call_id" => "",
|
|
90
|
+
"args" => { "name" => name.to_s }, "result" => result,
|
|
91
|
+
"ms" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
|
|
92
|
+
"at" => Time.now.utc.iso8601 }
|
|
93
|
+
)
|
|
94
|
+
rescue StandardError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
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,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Tools
|
|
7
|
+
# The agent's deterministic "I cannot proceed" signal (WS5). A
|
|
8
|
+
# system builtin like remember: the model calls it when it determines it cannot
|
|
9
|
+
# continue (out of scope, missing data, a case a human must take over). It ends
|
|
10
|
+
# the turn with `outcome: :stuck` recorded in the contract (the executor reads
|
|
11
|
+
# `state.stuck_outcome` at stage 8/9 and tags the terminal event) and a final
|
|
12
|
+
# message: the model's lead-in when it wrote one, else this tool's `message`.
|
|
13
|
+
#
|
|
14
|
+
# The ENGINE does not decide what "stuck" means — the consumer does. This tool
|
|
15
|
+
# is the deterministic signal the Agent.Shop subscribes to (`:turn_stuck` /
|
|
16
|
+
# `outcome: "stuck"`) to run its escalation (CRM/operator, which answers with
|
|
17
|
+
# `MessageOrigin::OPERATOR`). Nothing about handoff, pause or resume lives here.
|
|
18
|
+
class StuckSignal < RubyLLM::Tool
|
|
19
|
+
description "Signal that you cannot proceed and end the turn. Use when the " \
|
|
20
|
+
"request is out of your scope, you lack the data to help, or a human " \
|
|
21
|
+
"must take over. Write your final sentence to the customer first."
|
|
22
|
+
param :reason, desc: "Why you cannot proceed (goes to the operator, not the customer)"
|
|
23
|
+
param :message, desc: "Optional final message if you wrote none", required: false
|
|
24
|
+
|
|
25
|
+
def name = "signal_stuck"
|
|
26
|
+
|
|
27
|
+
def initialize(state:, **)
|
|
28
|
+
@state = state
|
|
29
|
+
super()
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def execute(reason:, message: nil)
|
|
33
|
+
@state.stuck_outcome = { reason: reason.to_s, message: message.to_s }
|
|
34
|
+
# A Halt ends the tool loop here, so the turn cannot continue after declaring
|
|
35
|
+
# stuck. The payload's `say` is the fallback final message when the model
|
|
36
|
+
# wrote no lead-in (the executor's halt_answer already prefers the lead-in).
|
|
37
|
+
RubyLLM::Tool::Halt.new(Insika::ToolDefinition.wrap_halt(
|
|
38
|
+
{ "reason" => reason.to_s },
|
|
39
|
+
message.to_s
|
|
40
|
+
))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
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 — 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.
|
|
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 (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 (R3).
|
|
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: 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
|