insika 0.9.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +25 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/profile_source.rb +4 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1453 -161
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +171 -9
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +152 -117
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +103 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +23 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +7 -5
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
data/lib/insika/tool_envelope.rb
CHANGED
|
@@ -9,13 +9,29 @@ module Insika
|
|
|
9
9
|
# + recording of a non-idempotent side-effect BEFORE the result returns to the
|
|
10
10
|
# model. Delegates everything else (name/description/params) to the real tool.
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
# the Executor never drives roundtrips.
|
|
12
|
+
# RubyLLM executes tools; this decorator enforces Insika's per-call policy.
|
|
14
13
|
class ToolEnvelope < SimpleDelegator
|
|
14
|
+
# Fiber-local slot where a tool leaves its timing breakdown for this call's trace.
|
|
15
|
+
TIMING_KEY = :insika_tool_timing
|
|
16
|
+
|
|
15
17
|
PROVENANCE_INSTRUCTION = "This value was not returned by any tool in this conversation. " \
|
|
16
18
|
"Find it with a tool that returns it — a search or a lookup by id — " \
|
|
17
19
|
"then call this tool again with an id from that result."
|
|
18
20
|
|
|
21
|
+
# The words the model reads when a call is held. They are the only thing
|
|
22
|
+
# standing between a correct store and a customer who was told the opposite:
|
|
23
|
+
# the engine can refuse the write, it cannot refuse the sentence. Measured —
|
|
24
|
+
# one reply in twenty announced a held purchase as closed — so the reply's
|
|
25
|
+
# SHAPE is stated before anything else, and the lie is named concretely
|
|
26
|
+
# rather than as "do not say it happened".
|
|
27
|
+
CONFIRMATION_INSTRUCTION = "NOT DONE — nothing was written, and nothing will be until the customer " \
|
|
28
|
+
"answers this very message. Your reply must ASK: say exactly what will " \
|
|
29
|
+
"happen, with these arguments, and end with the question. A reply that " \
|
|
30
|
+
"reports it as finished — 'done', 'closed', 'created', any past tense — is " \
|
|
31
|
+
"false, and the customer will act on it. On their next message: if they " \
|
|
32
|
+
"confirm, call confirm_pending with this pending_id; if they decline, " \
|
|
33
|
+
"change anything, or ask for something else, call cancel_pending."
|
|
34
|
+
|
|
19
35
|
# The tool timeout's OWN class: distinct from Async::TimeoutError so that
|
|
20
36
|
# the rescue below NEVER swallows the TURN timeout (which uses the default of
|
|
21
37
|
# with_timeout). Without this, a turn overflowing while the fiber is inside a
|
|
@@ -24,6 +40,11 @@ module Insika
|
|
|
24
40
|
ToolTimeout = Class.new(StandardError)
|
|
25
41
|
private_constant :ToolTimeout
|
|
26
42
|
|
|
43
|
+
# RubyLLM asks the tool's CLASS whether to defer its definition (tool
|
|
44
|
+
# search), and a delegator's class is this one. No Insika tool defers, so
|
|
45
|
+
# every envelope is sent as an ordinary tool.
|
|
46
|
+
def self.deferred? = false
|
|
47
|
+
|
|
27
48
|
# A gate's refusal. A plain Hash subclass: it reaches the model exactly as the
|
|
28
49
|
# `{status:, gate:, ...}` it always was, and the engine's own readers (the
|
|
29
50
|
# :tool_result outcome, the trace) recognize a refusal by CLASS — a data tool
|
|
@@ -31,6 +52,11 @@ module Insika
|
|
|
31
52
|
# not one, whatever keys it happens to use.
|
|
32
53
|
class Blocked < Hash; end
|
|
33
54
|
|
|
55
|
+
# A call held for the customer's confirmation. Same Hash-subclass trick as
|
|
56
|
+
# Blocked, and a different class on purpose: nothing was refused and nothing
|
|
57
|
+
# ran — the next customer message decides, through confirm_pending.
|
|
58
|
+
class Held < Hash; end
|
|
59
|
+
|
|
34
60
|
def initialize(tool, state:, checkpoint_store:, tool_registry:, timeout:,
|
|
35
61
|
skip_side_effects: [], trace_recorder: nil, event_stream: nil)
|
|
36
62
|
super(tool)
|
|
@@ -39,19 +65,51 @@ module Insika
|
|
|
39
65
|
@tool_registry = tool_registry
|
|
40
66
|
@timeout = timeout
|
|
41
67
|
@event_stream = event_stream
|
|
42
|
-
@skip_side_effects = Array(skip_side_effects) # ids already completed in the interrupted turn
|
|
68
|
+
@skip_side_effects = Array(skip_side_effects).dup # ids already completed in the interrupted turn
|
|
43
69
|
@trace_recorder = trace_recorder # duck-type: #record(session_id:, entry:). nil = no trace.
|
|
44
70
|
end
|
|
45
71
|
|
|
46
72
|
# Entry point that RubyLLM invokes (Tool#call in the pinned version).
|
|
47
73
|
# A timeout overflow returns to the MODEL as a serialized error — it does
|
|
48
74
|
# not bring down the turn.
|
|
49
|
-
def call(args)
|
|
75
|
+
def call(args = {}, tool_call: nil, **kwargs)
|
|
76
|
+
@state.current_tool_call = tool_call if tool_call
|
|
77
|
+
run(args.merge(kwargs), hold: true, native_call: tool_call && native_approvals?)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def requires_approval? = native_approvals? && approval_required?
|
|
81
|
+
|
|
82
|
+
def approval_tool_name = real_name
|
|
83
|
+
|
|
84
|
+
# RubyLLM queries this while inspecting and partitioning pending calls.
|
|
85
|
+
# Keep it read-only; policy refusals still return through #call as tool results.
|
|
86
|
+
def approval_resolver
|
|
87
|
+
->(call) do
|
|
88
|
+
next true if @skip_side_effects.include?(call.id.to_s) || provenance_block(call.arguments) || customer_confirm?
|
|
89
|
+
|
|
90
|
+
native_approval_decision(call.arguments, call.id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The confirmed re-run of a held call (Tools::ConfirmPending): every check but
|
|
95
|
+
# the hold itself — provenance, approval, fencing, evidence, the side-effect
|
|
96
|
+
# record and the trace all still apply to the write.
|
|
97
|
+
def call_confirmed(args) = run(args, hold: false)
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def run(args, hold:, native_call: false)
|
|
50
102
|
# A non-idempotent tool call ALREADY COMPLETED in the interrupted
|
|
51
103
|
# turn -> respond with a marker, NEVER re-execute. The marker returns to
|
|
52
104
|
# the model, keeping the tool-use protocol intact.
|
|
53
105
|
call_id = correlation_id
|
|
54
|
-
|
|
106
|
+
continuation = native_call && @state.approval_continuation
|
|
107
|
+
if call_id && @skip_side_effects.include?(call_id)
|
|
108
|
+
halt = continuation && continuation.dig("halts", call_id)
|
|
109
|
+
return ToolDefinition::Halt.new(content: halt["content"]) if halt
|
|
110
|
+
|
|
111
|
+
return { "skipped" => "already_executed" }
|
|
112
|
+
end
|
|
55
113
|
|
|
56
114
|
started = monotonic
|
|
57
115
|
if (blocked = provenance_block(args))
|
|
@@ -60,28 +118,44 @@ module Insika
|
|
|
60
118
|
return blocked
|
|
61
119
|
end
|
|
62
120
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
121
|
+
# Customer confirmation: the call is recorded and returned to the model as
|
|
122
|
+
# held; the turn goes on to ask. After the provenance gate (an id the customer
|
|
123
|
+
# never saw is not put to them as real) and before approval.
|
|
124
|
+
if hold && (held = confirmation_hold(args))
|
|
125
|
+
trace(call_id, args, held, started)
|
|
126
|
+
return held
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Native calls recheck the persisted decision after RubyLLM's resolver.
|
|
130
|
+
# Direct/workflow calls still create and await approval through the mailbox.
|
|
131
|
+
# Cancellation and turn timeout propagate from that wait (not ToolTimeout).
|
|
69
132
|
if approval_required?
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
133
|
+
approved = if native_call
|
|
134
|
+
native_approval_decision(args, call_id) == true
|
|
135
|
+
else
|
|
136
|
+
@state.approval_coordinator.request_approval(
|
|
137
|
+
task: @state.task, turn: @state.turn, tool: real_name, args: args, actor: @state.actor
|
|
138
|
+
).to_s == "approved"
|
|
139
|
+
end
|
|
140
|
+
return { error: "rejected by operator" } unless approved
|
|
74
141
|
end
|
|
75
142
|
|
|
76
143
|
started = monotonic
|
|
77
|
-
|
|
144
|
+
Thread.current[TIMING_KEY] = nil
|
|
145
|
+
result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { invoke(args) } }
|
|
78
146
|
# the ONE seam every tool result passes on its way to the model.
|
|
79
147
|
# For a declared-evidence tool: reshape to the lean envelope, record the ids
|
|
80
148
|
# on the ledger, hoard the attachments. No evidence = the result passes
|
|
81
149
|
# through untouched (one nil-check — parity).
|
|
150
|
+
capture_shared_result(call_id, result)
|
|
82
151
|
result = process_evidence(result)
|
|
83
152
|
result = fence(result)
|
|
84
|
-
|
|
153
|
+
if side_effect?
|
|
154
|
+
halt = { "content" => result.content } if continuation && result.is_a?(ToolDefinition::Halt)
|
|
155
|
+
record_side_effect!(call_id, **(halt ? { halt: halt } : {}))
|
|
156
|
+
(continuation["halts"] ||= {})[call_id] = halt if halt
|
|
157
|
+
@skip_side_effects << call_id if native_call
|
|
158
|
+
end
|
|
85
159
|
trace(call_id, args, result, started)
|
|
86
160
|
result
|
|
87
161
|
rescue ToolTimeout
|
|
@@ -90,10 +164,17 @@ module Insika
|
|
|
90
164
|
err
|
|
91
165
|
end
|
|
92
166
|
|
|
93
|
-
private
|
|
94
|
-
|
|
95
167
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
168
|
|
|
169
|
+
def invoke(args)
|
|
170
|
+
tool = __getobj__
|
|
171
|
+
if tool.respond_to?(:parameters_schema)
|
|
172
|
+
tool.call(**args.transform_keys(&:to_sym), tool_call: @state.current_tool_call)
|
|
173
|
+
else
|
|
174
|
+
tool.call(args)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
97
178
|
# with parallel tool calls on, the turn's shared semaphore
|
|
98
179
|
# (TurnState#tool_gate, sized by `limits[:tool_concurrency]`) caps how many run
|
|
99
180
|
# at once. Wraps the REAL call ONLY — the approval wait and the skip check are
|
|
@@ -144,11 +225,46 @@ module Insika
|
|
|
144
225
|
# (DataDefinedTool exposes its definition's schema; a code tool exposes none
|
|
145
226
|
# -> every declared parameter is treated as required).
|
|
146
227
|
def optional_params(tool)
|
|
147
|
-
return [] unless tool.respond_to?(:
|
|
228
|
+
return [] unless tool.respond_to?(:parameters_schema) && (schema = tool.parameters_schema).is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
schema = Coercion.deep_stringify(schema)
|
|
148
231
|
|
|
149
232
|
(schema["properties"] || {}).keys.map(&:to_s) - Array(schema["required"]).map(&:to_s)
|
|
150
233
|
end
|
|
151
234
|
|
|
235
|
+
def confirmation_hold(args)
|
|
236
|
+
return unless customer_confirm?
|
|
237
|
+
|
|
238
|
+
store = @state.respond_to?(:pending_action_store) ? @state.pending_action_store : nil
|
|
239
|
+
task = @state.task
|
|
240
|
+
if store.nil? || task.nil? || task.session_id.nil?
|
|
241
|
+
# Fail loud, never run: a write that needs the customer's word and has
|
|
242
|
+
# nowhere to wait for it is a misconfiguration, not a pass.
|
|
243
|
+
missing = store.nil? ? "no PendingActionStore" : "no session"
|
|
244
|
+
raise Insika::Error, "tool '#{real_name}' needs the customer's confirmation but the turn has #{missing} to hold it in"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
id = PendingActionStore.confirmation_id(task.session_id, real_name)
|
|
248
|
+
store.create(id: id, task_id: task.id, session_id: task.session_id, turn: @state.turn,
|
|
249
|
+
tool: real_name, args: args || {}, kind: PendingActionStore::CUSTOMER)
|
|
250
|
+
@event_stream&.emit(Insika::Event.new(
|
|
251
|
+
type: :confirmation_requested,
|
|
252
|
+
data: { pending_id: id, tool: real_name, args: args },
|
|
253
|
+
meta: { task_id: task.id, session_id: task.session_id }
|
|
254
|
+
))
|
|
255
|
+
# Key order is what the model reads first: the verdict, then the instruction,
|
|
256
|
+
# then the echo of what it asked for — the echo alone reads like a success.
|
|
257
|
+
Held[{ "executed" => false, "status" => "pending_confirmation", "gate" => "confirmation",
|
|
258
|
+
"instruction" => CONFIRMATION_INSTRUCTION, "tool" => real_name, "args" => args,
|
|
259
|
+
"pending_id" => id }]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def customer_confirm?
|
|
263
|
+
profile = @state.respond_to?(:profile) ? @state.profile : nil
|
|
264
|
+
list = profile.respond_to?(:customer_confirm) ? profile.customer_confirm : nil
|
|
265
|
+
Array(list).include?(real_name)
|
|
266
|
+
end
|
|
267
|
+
|
|
152
268
|
def emit_blocked(result)
|
|
153
269
|
task = @state.task # nil on a one-shot turn, like `trace` already assumes
|
|
154
270
|
@event_stream&.emit(Insika::Event.new(
|
|
@@ -161,16 +277,27 @@ module Insika
|
|
|
161
277
|
# Records the call for debugging in the Studio (name + model args + result +
|
|
162
278
|
# ms), keyed by the SESSION. Masking/truncation is the ToolTraceStore's job;
|
|
163
279
|
# here we only collect. NEVER breaks the turn (trace is observability).
|
|
280
|
+
def capture_shared_result(call_id, result)
|
|
281
|
+
return unless @state.respond_to?(:shared_conversations) && @state.shared_conversations
|
|
282
|
+
|
|
283
|
+
@state.shared_conversations.record_tool_result(task: @state.task, call_id: call_id, result: result)
|
|
284
|
+
end
|
|
285
|
+
|
|
164
286
|
def trace(call_id, args, result, started)
|
|
287
|
+
capture_shared_result(call_id, result)
|
|
288
|
+
timing = Thread.current[TIMING_KEY] || {}
|
|
289
|
+
Thread.current[TIMING_KEY] = nil
|
|
290
|
+
ms = started ? ((monotonic - started) * 1000).round : nil
|
|
291
|
+
TurnTiming.current&.tool(real_name, ToolTraceStore.ok?(result), ms)
|
|
165
292
|
return unless @trace_recorder && @state.task&.session_id
|
|
166
293
|
|
|
167
294
|
@trace_recorder.record(
|
|
168
295
|
session_id: @state.task.session_id,
|
|
169
|
-
entry: { "turn" => @state.turn, "tool" => real_name, "call_id" => call_id.to_s,
|
|
296
|
+
entry: { "task_id" => @state.task.id.to_s, "turn" => @state.turn, "tool" => real_name, "call_id" => call_id.to_s,
|
|
170
297
|
"args" => args, "result" => result,
|
|
171
|
-
"gate" => result.is_a?(Blocked) ? result["gate"] : nil,
|
|
172
|
-
"ms" =>
|
|
173
|
-
"at" => Time.now.utc.iso8601 }
|
|
298
|
+
"gate" => result.is_a?(Blocked) || result.is_a?(Held) ? result["gate"] : nil,
|
|
299
|
+
"ms" => ms,
|
|
300
|
+
"at" => Time.now.utc.iso8601 }.merge(timing)
|
|
174
301
|
)
|
|
175
302
|
rescue StandardError
|
|
176
303
|
nil
|
|
@@ -187,8 +314,18 @@ module Insika
|
|
|
187
314
|
# Does the current tool require approval? (names come from the Resolution
|
|
188
315
|
# via state).
|
|
189
316
|
def approval_required?
|
|
190
|
-
@state.respond_to?(:requires_approval) &&
|
|
191
|
-
|
|
317
|
+
(@state.respond_to?(:requires_approval) && Array(@state.requires_approval).include?(real_name)) ||
|
|
318
|
+
(__getobj__.respond_to?(:requires_approval?) && __getobj__.requires_approval?)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def native_approvals?
|
|
322
|
+
@state.respond_to?(:native_approvals) && @state.native_approvals == true
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def native_approval_decision(args, call_id)
|
|
326
|
+
@state.approval_coordinator.approval_decision(
|
|
327
|
+
task: @state.task, turn: @state.turn, tool: real_name, args: args, call_id: call_id
|
|
328
|
+
)
|
|
192
329
|
end
|
|
193
330
|
|
|
194
331
|
# The call's correlation: the provider id (RubyLLM chat, via
|
|
@@ -208,11 +345,11 @@ module Insika
|
|
|
208
345
|
end
|
|
209
346
|
|
|
210
347
|
# Written BEFORE the tool result returns to the model.
|
|
211
|
-
def record_side_effect!(call_id)
|
|
348
|
+
def record_side_effect!(call_id, **metadata)
|
|
212
349
|
return if call_id.to_s.empty?
|
|
213
350
|
|
|
214
351
|
@checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
|
|
215
|
-
tool_call_id: call_id)
|
|
352
|
+
tool_call_id: call_id, **metadata)
|
|
216
353
|
end
|
|
217
354
|
|
|
218
355
|
# ---- fencing ----------------------------------------------
|
data/lib/insika/tool_manifest.rb
CHANGED
|
@@ -73,6 +73,7 @@ module Insika
|
|
|
73
73
|
"response" => binding[:response],
|
|
74
74
|
"secret_headers" => binding[:secret_headers],
|
|
75
75
|
"side_effect" => t["side_effect"],
|
|
76
|
+
"timeout" => t["timeout"],
|
|
76
77
|
"group" => t["group"] || defaults["group"], # per-group allowlist:
|
|
77
78
|
"tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
|
|
78
79
|
# Per-tool only: "this result ends the turn" is a property of THIS backend's
|
data/lib/insika/tool_store.rb
CHANGED
|
@@ -35,6 +35,9 @@ module Insika
|
|
|
35
35
|
# -> [String] names, lexicographic order.
|
|
36
36
|
def names = @cs.keys(SCOPE)
|
|
37
37
|
|
|
38
|
+
# Changes whenever any config is written, in any process (ConfigStore#version_token).
|
|
39
|
+
def version_token = @cs.version_token
|
|
40
|
+
|
|
38
41
|
# -> [Hash] MASKED definitions (for the UI).
|
|
39
42
|
def all
|
|
40
43
|
names.filter_map { |n| get(n) }
|
|
@@ -37,6 +37,11 @@ module Insika
|
|
|
37
37
|
nil
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Conventional tool error = Hash with key "error"/:error (everything else is ok).
|
|
41
|
+
def self.ok?(result)
|
|
42
|
+
!(result.is_a?(Hash) && (result.key?("error") || result.key?(:error)))
|
|
43
|
+
end
|
|
44
|
+
|
|
40
45
|
# -> [Hash] session entries in chronological order. [] if none.
|
|
41
46
|
def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
|
|
42
47
|
|
|
@@ -49,19 +54,21 @@ module Insika
|
|
|
49
54
|
e = stringify_keys(entry)
|
|
50
55
|
{
|
|
51
56
|
"turn" => e["turn"], "tool" => e["tool"].to_s, "call_id" => e["call_id"].to_s,
|
|
52
|
-
"ok" => ok?(e["result"]),
|
|
57
|
+
"ok" => self.class.ok?(e["result"]),
|
|
53
58
|
"args" => clip(mask(e["args"])), "result" => clip(mask(e["result"])),
|
|
54
59
|
"ms" => e["ms"], "at" => e["at"].to_s
|
|
55
|
-
}.tap
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
}.tap do |trace|
|
|
61
|
+
trace["task_id"] = e["task_id"].to_s if e["task_id"]
|
|
62
|
+
trace["gate"] = e["gate"].to_s if e["gate"]
|
|
63
|
+
# Timing breakdown a data-tool reports (see DataDefinedTool#record_timing).
|
|
64
|
+
%w[pre_ms http_ms server_ms].each { |k| trace[k] = e[k] if e[k].is_a?(Integer) }
|
|
65
|
+
end
|
|
61
66
|
end
|
|
62
67
|
|
|
63
68
|
# Masks values of sensitive keys (recursive); the rest pass through intact.
|
|
64
|
-
def mask(obj)
|
|
69
|
+
def mask(obj) = self.class.mask(obj)
|
|
70
|
+
|
|
71
|
+
def self.mask(obj)
|
|
65
72
|
case obj
|
|
66
73
|
when Hash
|
|
67
74
|
obj.each_with_object({}) do |(k, v), acc|
|
|
@@ -87,8 +87,8 @@ module Insika
|
|
|
87
87
|
# workflows without a chat) contributes nothing.
|
|
88
88
|
def sessions_by_agent
|
|
89
89
|
acc = Hash.new { |h, k| h[k] = [] }
|
|
90
|
-
@task_store.each_id
|
|
91
|
-
|
|
90
|
+
tasks = @task_store.respond_to?(:all) ? @task_store.all : @task_store.each_id.filter_map { |id| @task_store.find(id) }
|
|
91
|
+
tasks.each do |task|
|
|
92
92
|
agent = task.command.is_a?(Hash) ? task.command.dig("payload", "agent") : nil
|
|
93
93
|
next if agent.to_s.empty? || task.session_id.to_s.empty?
|
|
94
94
|
|
|
@@ -9,7 +9,7 @@ module Insika
|
|
|
9
9
|
# enter lib/insika.rb; it is loaded in the registration block (wiring) on the 1st
|
|
10
10
|
# instance. One instance per remote agent (its own name/description).
|
|
11
11
|
class A2ARemote < RubyLLM::Tool
|
|
12
|
-
|
|
12
|
+
parameter :message, description: "The message/task for the remote agent"
|
|
13
13
|
|
|
14
14
|
def initialize(client:, url:, tool_name:, description:, event_stream: nil)
|
|
15
15
|
@client = client
|
|
@@ -11,7 +11,7 @@ module Insika
|
|
|
11
11
|
# the provider sees.
|
|
12
12
|
#
|
|
13
13
|
# Tolerant by construction: JSON-schema hashes reach us with symbol keys (the
|
|
14
|
-
# class-level `
|
|
14
|
+
# class-level `parameters(...)` form) or string keys (anything that round-tripped
|
|
15
15
|
# through JSON), and a shape we do not recognize is returned UNTOUCHED — a
|
|
16
16
|
# schema we failed to annotate still works, one we corrupted would break every
|
|
17
17
|
# delegation.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Tools
|
|
7
|
+
# What the two confirmation tools share: finding the customer's hold and
|
|
8
|
+
# refusing anyone else's — another conversation's, the operator's, or one
|
|
9
|
+
# already decided. Errors go back to the MODEL as bodies, never as
|
|
10
|
+
# exceptions: a tool exception never ends the turn.
|
|
11
|
+
module Confirmation
|
|
12
|
+
CUSTOMER = PendingActionStore::CUSTOMER
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# -> [PendingAction, nil] | [nil, String]
|
|
17
|
+
def open_hold(state, pending_id)
|
|
18
|
+
store = state.pending_action_store
|
|
19
|
+
pending = store&.find(pending_id.to_s)
|
|
20
|
+
return [nil, "no held action with pending_id #{pending_id}"] if pending.nil?
|
|
21
|
+
return [nil, "#{pending_id} is not a customer confirmation"] unless pending.kind == CUSTOMER
|
|
22
|
+
return [nil, "#{pending_id} belongs to another conversation"] unless pending.session_id.to_s == state.task.session_id.to_s
|
|
23
|
+
# The hold and the answer to it are two customer messages, which is two
|
|
24
|
+
# TASKS. A hold THIS task created has not been put to the customer yet —
|
|
25
|
+
# the reply carrying it has not even been sent. Deciding it here would be
|
|
26
|
+
# the model confirming itself, and the write would land with nobody's word
|
|
27
|
+
# behind it.
|
|
28
|
+
return [nil, "#{pending_id} was held on this same turn; the customer has not answered it yet"] if pending.task_id.to_s == state.task.id.to_s
|
|
29
|
+
return [nil, "#{pending_id} was already #{pending.status}"] unless pending.status == :pending
|
|
30
|
+
|
|
31
|
+
[pending, nil]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The turn's enveloped instance of the held tool — the same wrapper the
|
|
35
|
+
# hold came from, so the confirmed run pays every check but the hold.
|
|
36
|
+
def held_tool(state, name)
|
|
37
|
+
Array(state.allowed_tools).find do |tool|
|
|
38
|
+
tool.respond_to?(:call_confirmed) &&
|
|
39
|
+
(tool.name.to_s == name || (tool.respond_to?(:impl_name) && tool.impl_name.to_s == name))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def emit(event_stream, state, type, pending)
|
|
44
|
+
event_stream&.emit(Insika::Event.new(
|
|
45
|
+
type: type,
|
|
46
|
+
data: { pending_id: pending.id, tool: pending.tool },
|
|
47
|
+
meta: { task_id: state.task.id, session_id: state.task.session_id }
|
|
48
|
+
))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Runs an action the engine held for the customer's confirmation. The
|
|
53
|
+
# decision is written FIRST (durable — a crash between the two re-executes
|
|
54
|
+
# the turn and finds it approved), the original tool runs SECOND through its
|
|
55
|
+
# own envelope with the arguments recorded at the hold: there is no path to
|
|
56
|
+
# confirm different arguments than the ones the customer was shown.
|
|
57
|
+
# System builtin (like remember): `require "ruby_llm"` stays in THIS file,
|
|
58
|
+
# loaded lazily by the Executor in create_chat.
|
|
59
|
+
class ConfirmPending < RubyLLM::Tool
|
|
60
|
+
description "Runs an action the customer has just confirmed. Call it only when the customer's " \
|
|
61
|
+
"message agrees to exactly what was proposed; a change, a question or a new request " \
|
|
62
|
+
"is cancel_pending instead."
|
|
63
|
+
parameter :pending_id, description: "The pending_id the held action returned"
|
|
64
|
+
|
|
65
|
+
def name = "confirm_pending"
|
|
66
|
+
|
|
67
|
+
def initialize(event_stream:, state:)
|
|
68
|
+
@event_stream = event_stream
|
|
69
|
+
@state = state
|
|
70
|
+
super()
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def execute(pending_id:)
|
|
74
|
+
pending, error = Confirmation.open_hold(@state, pending_id)
|
|
75
|
+
return { error: error } if error
|
|
76
|
+
|
|
77
|
+
tool = Confirmation.held_tool(@state, pending.tool)
|
|
78
|
+
return { error: "#{pending.tool} is not available on this turn" } unless tool
|
|
79
|
+
|
|
80
|
+
@state.pending_action_store.resolve(pending.id, decision: :approved, operator: Confirmation::CUSTOMER)
|
|
81
|
+
Confirmation.emit(@event_stream, @state, :confirmation_confirmed, pending)
|
|
82
|
+
tool.call_confirmed(pending.args || {})
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Drops a held action: the customer declined, changed something, or asked
|
|
87
|
+
# for anything else. The hold would expire on its own at the end of this
|
|
88
|
+
# turn; cancelling says so now, and lets the model answer accordingly.
|
|
89
|
+
class CancelPending < RubyLLM::Tool
|
|
90
|
+
description "Drops an action that was waiting for the customer's confirmation, because they " \
|
|
91
|
+
"declined, changed something, or asked for something else."
|
|
92
|
+
parameter :pending_id, description: "The pending_id the held action returned"
|
|
93
|
+
|
|
94
|
+
def name = "cancel_pending"
|
|
95
|
+
|
|
96
|
+
def initialize(event_stream:, state:)
|
|
97
|
+
@event_stream = event_stream
|
|
98
|
+
@state = state
|
|
99
|
+
super()
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def execute(pending_id:)
|
|
103
|
+
pending, error = Confirmation.open_hold(@state, pending_id)
|
|
104
|
+
return { error: error } if error
|
|
105
|
+
|
|
106
|
+
@state.pending_action_store.resolve(pending.id, decision: :rejected, operator: Confirmation::CUSTOMER)
|
|
107
|
+
Confirmation.emit(@event_stream, @state, :confirmation_cancelled, pending)
|
|
108
|
+
{ "status" => "cancelled", "tool" => pending.tool }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -12,7 +12,7 @@ module Insika
|
|
|
12
12
|
# it is NOT required in lib/insika.rb; the overlay loads it lazily at registration
|
|
13
13
|
#
|
|
14
14
|
# Contract preserved by duck-typing: it overrides name/description/parameters/
|
|
15
|
-
# execute; RubyLLM's
|
|
15
|
+
# execute; RubyLLM's parameters_schema derives from #parameters automatically.
|
|
16
16
|
# execute NEVER raises — an error (missing param, blocked egress, HTTP, parse)
|
|
17
17
|
# becomes `{ error: }` to the model, like the other tools.
|
|
18
18
|
class DataDefinedTool < RubyLLM::Tool
|
|
@@ -49,20 +49,11 @@ module Insika
|
|
|
49
49
|
def evidence = @definition.evidence
|
|
50
50
|
def requires_evidence = @definition.requires_evidence
|
|
51
51
|
|
|
52
|
-
# FULL (nested) JSON Schema straight into RubyLLM's
|
|
52
|
+
# FULL (nested) JSON Schema straight into RubyLLM's parameters_schema — it is what
|
|
53
53
|
# the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
|
|
54
|
-
#
|
|
54
|
+
# parameters_schema; parameters is just a fallback). Provider-agnostic and
|
|
55
55
|
# the only form that expresses nesting (object/array/enum).,.
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
# FLAT top-level view for discovery (tool_search calls #parameters on the resolved
|
|
59
|
-
# tool). The real nested schema goes through #params_schema above.
|
|
60
|
-
def parameters
|
|
61
|
-
@parameters ||= @definition.top_level_params.each_with_object({}) do |p, acc|
|
|
62
|
-
sym = p[:name].to_sym
|
|
63
|
-
acc[sym] = RubyLLM::Parameter.new(sym, type: p[:type], desc: p[:description], required: p[:required])
|
|
64
|
-
end
|
|
65
|
-
end
|
|
56
|
+
def parameters_schema = @definition.parameters
|
|
66
57
|
|
|
67
58
|
# The args are checked against the tool's own JSON Schema BEFORE the request is
|
|
68
59
|
# built: a call the schema does not allow becomes `{ error: }` the model can act
|
|
@@ -72,16 +63,19 @@ module Insika
|
|
|
72
63
|
return { error: bad }
|
|
73
64
|
end
|
|
74
65
|
|
|
66
|
+
t0 = monotonic
|
|
75
67
|
req = build_request(kwargs)
|
|
76
68
|
reason = @egress.violation(req[:url], **@egress_options)
|
|
77
69
|
return { error: "destination blocked: #{reason}" } if reason
|
|
78
70
|
|
|
71
|
+
t1 = monotonic
|
|
79
72
|
result = @http.request(**req)
|
|
73
|
+
record_timing(t0, t1, result)
|
|
80
74
|
emit(result[:status])
|
|
81
75
|
payload = extract(result)
|
|
82
76
|
# The RESPONSE says the turn is over (`halt_when`): the backend already
|
|
83
77
|
# answered the customer, so letting the model comment would deliver the
|
|
84
|
-
# message twice.
|
|
78
|
+
# message twice. Insika::ToolDefinition::Halt ends its loop right here — no second
|
|
85
79
|
# provider call, and the decision is the engine's, not a request in a prompt.
|
|
86
80
|
# Only on a 2xx: an error body that happens to carry the value is a failure,
|
|
87
81
|
# and a failure must reach the model.
|
|
@@ -90,7 +84,7 @@ module Insika
|
|
|
90
84
|
# the model wrote no lead-in. Wrapped only when there is one, so every tool
|
|
91
85
|
# that declares no `say` keeps producing exactly the payload it always did.
|
|
92
86
|
say = @definition.halt_say(result[:body])
|
|
93
|
-
return
|
|
87
|
+
return Insika::ToolDefinition::Halt.new(say ? Insika::ToolDefinition.wrap_halt(payload, say) : payload)
|
|
94
88
|
end
|
|
95
89
|
|
|
96
90
|
payload
|
|
@@ -220,6 +214,20 @@ module Insika
|
|
|
220
214
|
|
|
221
215
|
# No task correlation (registry tool does not receive TurnState) -> meta {}.
|
|
222
216
|
# Emits only name + status: NEVER body/headers (0 secret leakage, R2).
|
|
217
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
218
|
+
|
|
219
|
+
# Where the call's time went, for the ToolEnvelope's trace (same fiber): the
|
|
220
|
+
# request build + egress check, the HTTP round trip as this process saw it, and
|
|
221
|
+
# the server's own runtime when it reports one. A wide gap between http_ms and
|
|
222
|
+
# server_ms is network or this process being slow to resume the call.
|
|
223
|
+
def record_timing(t0, t1, result)
|
|
224
|
+
Thread.current[Insika::ToolEnvelope::TIMING_KEY] = {
|
|
225
|
+
"pre_ms" => ((t1 - t0) * 1000).round,
|
|
226
|
+
"http_ms" => ((monotonic - t1) * 1000).round,
|
|
227
|
+
"server_ms" => result[:server_ms]
|
|
228
|
+
}.compact
|
|
229
|
+
end
|
|
230
|
+
|
|
223
231
|
def emit(status)
|
|
224
232
|
@event_stream&.emit(Insika::Event.new(
|
|
225
233
|
type: :data_tool_call, data: { tool: @definition.name, status: status }, meta: {}
|
|
@@ -27,9 +27,9 @@ module Insika
|
|
|
27
27
|
"prompt alone — UNLESS this turn carries an inbound photo, in which case " \
|
|
28
28
|
"that photo is edited by default (pass source_image_urls explicitly to " \
|
|
29
29
|
"generate from scratch instead)."
|
|
30
|
-
# explicit JSON-schema form (the `
|
|
30
|
+
# explicit JSON-schema form (the `parameter` DSL only reaches strings/scalars,
|
|
31
31
|
# and source_image_urls needs a typed array — the bare-array gotcha, #128).
|
|
32
|
-
|
|
32
|
+
parameters(
|
|
33
33
|
type: "object",
|
|
34
34
|
properties: {
|
|
35
35
|
prompt: { type: "string", description: "What to draw, or what edit to make, in detail" },
|
|
@@ -16,7 +16,7 @@ module Insika
|
|
|
16
16
|
# loads it lazily inside create_chat, same as load_skill.
|
|
17
17
|
class LoadKnowledge < RubyLLM::Tool
|
|
18
18
|
description "Loads the complete content of a learned concept by name"
|
|
19
|
-
|
|
19
|
+
parameter :name, description: "Exact concept name, as listed in <knowledge>"
|
|
20
20
|
|
|
21
21
|
# RubyLLM::Tool#name derives from self.class.name — for a nested class it
|
|
22
22
|
# produces "insika--tools--load_knowledge", not "load_knowledge" (which
|
|
@@ -47,7 +47,11 @@ module Insika
|
|
|
47
47
|
private
|
|
48
48
|
|
|
49
49
|
def load(name)
|
|
50
|
-
content = @
|
|
50
|
+
content = if @state&.respond_to?(:shared_conversations) && @state.shared_conversations
|
|
51
|
+
@state.shared_conversations.memory_get(task:@state.task,id:name.to_s,kind:'knowledge')&.fetch('value')
|
|
52
|
+
else
|
|
53
|
+
@store.get(@agent_id, name.to_s, tenant: @tenant)
|
|
54
|
+
end
|
|
51
55
|
return { error: "concept '#{name}' not found" } unless content
|
|
52
56
|
|
|
53
57
|
content
|
|
@@ -61,7 +65,7 @@ module Insika
|
|
|
61
65
|
|
|
62
66
|
@trace_recorder.record(
|
|
63
67
|
session_id: @state.task.session_id,
|
|
64
|
-
entry: { "turn" => @state.turn, "tool" => "load_knowledge", "call_id" => "",
|
|
68
|
+
entry: { "task_id" => @state.task.id.to_s, "turn" => @state.turn, "tool" => "load_knowledge", "call_id" => "",
|
|
65
69
|
"args" => { "name" => name.to_s }, "result" => result,
|
|
66
70
|
"ms" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
|
|
67
71
|
"at" => Time.now.utc.iso8601 }
|