insika 0.1.0 → 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 +69 -3
- data/README.md +1 -1
- data/bin/insika +22 -7
- data/docs/AGENTS.md +129 -5
- data/docs/CHANNELS.md +1 -1
- data/docs/CONTEXT.md +22 -5
- data/docs/DEPLOY.md +30 -10
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +1 -1
- data/docs/LOADTEST.md +3 -2
- data/docs/OBSERVABILITY.md +11 -2
- data/docs/REFINEMENT.md +6 -6
- data/docs/RELEASING.md +7 -7
- data/docs/RUNNING-LOCAL.md +1 -1
- data/docs/SECURITY.md +24 -11
- data/docs/SKILLS.md +189 -3
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/index.md +1 -1
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +89 -22
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/channel_delivery.rb +14 -11
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +3 -3
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +7 -7
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +62 -13
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +5 -3
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- 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 +5 -5
- data/lib/insika/commands/send_message.rb +9 -9
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/context/builder.rb +2 -2
- data/lib/insika/context/fragment.rb +27 -3
- data/lib/insika/context/priority.rb +3 -2
- data/lib/insika/context/providers/memory.rb +1 -1
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +5 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/doctor.rb +250 -5
- data/lib/insika/dsl/runtime.rb +12 -9
- data/lib/insika/dsl/server_boot.rb +4 -3
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +72 -15
- data/lib/insika/edge_limiter.rb +144 -6
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +13 -10
- data/lib/insika/errors.rb +61 -5
- data/lib/insika/evals/assertions.rb +12 -12
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +3 -3
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/executor.rb +423 -108
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/golden_store.rb +2 -2
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/message_origin.rb +2 -2
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/onboarding.rb +2 -2
- data/lib/insika/outbox_store.rb +2 -2
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/profile_source.rb +12 -6
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +2 -2
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +9 -9
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +2 -2
- data/lib/insika/safety/detectors.rb +5 -5
- data/lib/insika/safety/factory.rb +3 -3
- data/lib/insika/safety/input_guardrail.rb +19 -4
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +2 -2
- data/lib/insika/safety/output_validator.rb +2 -2
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/server/app.rb +223 -51
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +15 -7
- data/lib/insika/server/responses.rb +18 -8
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +3 -3
- data/lib/insika/session_store.rb +1 -1
- data/lib/insika/settings_store.rb +5 -5
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +127 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +1 -1
- data/lib/insika/studio/app.rb +183 -61
- data/lib/insika/studio/assets/dist/application.js +25 -24
- data/lib/insika/studio/forms.rb +13 -18
- data/lib/insika/studio/nav_icons.rb +1 -1
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +2 -2
- data/lib/insika/studio/views/agents.erb +1 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +78 -3
- data/lib/insika/studio/views/settings.erb +7 -12
- data/lib/insika/studio/views/skills.erb +67 -12
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +1 -1
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +27 -27
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +8 -8
- data/lib/insika/tool_envelope.rb +1 -1
- data/lib/insika/tool_manifest.rb +6 -6
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +4 -5
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +17 -13
- data/lib/insika/turn_timing.rb +2 -2
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +77 -26
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +32 -15
- metadata +19 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Mechanical (no-LLM) compression of tool output in the replayed transcript
|
|
7
|
+
# (A3/C3, hermes phase 1): identical tool results are stored ONCE — the first
|
|
8
|
+
# occurrence full — and every byte-identical repeat is replaced with a compact
|
|
9
|
+
# back-reference to it plus the original's first line. The cheap half of
|
|
10
|
+
# compaction: a search tool that returns the same catalog page over N turns
|
|
11
|
+
# currently pays for the full body N times, and that accumulation is what
|
|
12
|
+
# blows the budget first.
|
|
13
|
+
#
|
|
14
|
+
# Deliberately crude on purpose: SHA-256 digest, so ONLY exact matches dedupe —
|
|
15
|
+
# a differing detail is kept, never "close enough" dropped. Deterministic per
|
|
16
|
+
# input (the same transcript compresses identically every turn, prompt-cache
|
|
17
|
+
# friendly). Never touches user/assistant content and never mutates the input
|
|
18
|
+
# (symbol or string keys are preserved per message).
|
|
19
|
+
#
|
|
20
|
+
# Consumer: the history path of Context::Providers::Session, opt-in via
|
|
21
|
+
# AgentProfile#tool_output_compression. The LLM half of compaction (real
|
|
22
|
+
# summarization) remains F5 — decided with data, not by matrix.
|
|
23
|
+
class ToolOutputCompressor
|
|
24
|
+
# Below this a back-reference can never cost less than the body it
|
|
25
|
+
# replaces (an early bail; the REAL gate is the length check per repeat).
|
|
26
|
+
MIN_LENGTH = 200
|
|
27
|
+
# The mechanical "1-line summary": the original's first line, capped here
|
|
28
|
+
# (160 chars ≈ ~40 tokens — a bounded, informative stub).
|
|
29
|
+
SUMMARY_LIMIT = 160
|
|
30
|
+
|
|
31
|
+
# The boilerplate deliberately carries NO positional pointer ("see above"):
|
|
32
|
+
# the budget cut evicts the OLDEST unit first, which is the original this
|
|
33
|
+
# back-reference points at — a "↑" that survives its target leaves a dangle
|
|
34
|
+
# in the model's context (C3). The summary is the content; it stands alone.
|
|
35
|
+
BOILERPLATE = "[repeated tool output — byte-identical to an earlier result " \
|
|
36
|
+
"in this conversation; One-line summary: "
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# [message, ...] with repeated tool results back-referenced. A non-tool
|
|
40
|
+
# message or a tool result that is not a String passes through untouched.
|
|
41
|
+
# A repeat is replaced ONLY when the back-reference is strictly shorter
|
|
42
|
+
# than the content it would replace (in the 200–~260-char band a
|
|
43
|
+
# back-reference is LONGER than the original — replacing there would
|
|
44
|
+
# GROW the transcript, C3).
|
|
45
|
+
def compress_transcript(messages)
|
|
46
|
+
seen = {}
|
|
47
|
+
Array(messages).map do |m|
|
|
48
|
+
content = text_of(m)
|
|
49
|
+
next m unless content && content.length >= MIN_LENGTH
|
|
50
|
+
|
|
51
|
+
digest = Digest::SHA256.hexdigest(content)
|
|
52
|
+
if seen.key?(digest)
|
|
53
|
+
replacement = back_reference(seen[digest][:summary])
|
|
54
|
+
replacement.length < content.length ? replace_content(m, replacement) : m
|
|
55
|
+
else
|
|
56
|
+
seen[digest] = { summary: summarize(content) }
|
|
57
|
+
m
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# -> String | nil: the content of a role:tool message, nil otherwise
|
|
65
|
+
# (compression is for tool RESULTS only; a person's words are never touched).
|
|
66
|
+
def text_of(m)
|
|
67
|
+
return nil unless m.is_a?(Hash)
|
|
68
|
+
return nil unless (m[:role] || m["role"]).to_s == "tool"
|
|
69
|
+
|
|
70
|
+
c = m[:content] || m["content"]
|
|
71
|
+
c.is_a?(String) ? c : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def back_reference(summary)
|
|
75
|
+
"#{BOILERPLATE}#{summary}]"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A dup with the content replaced under the SAME key style as the original
|
|
79
|
+
# (symbol-keyed message -> symbol content, string-keyed -> string content).
|
|
80
|
+
def replace_content(m, text)
|
|
81
|
+
out = m.dup
|
|
82
|
+
if m.key?(:content)
|
|
83
|
+
out[:content] = text
|
|
84
|
+
else
|
|
85
|
+
out["content"] = text
|
|
86
|
+
end
|
|
87
|
+
out
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The mechanical one-line summary: the first line, whitespace-collapsed and
|
|
91
|
+
# capped. For a multi-line JSON tool body the first line is a real head; for
|
|
92
|
+
# a single-line blob it is a truncated head — never the whole body.
|
|
93
|
+
def summarize(content)
|
|
94
|
+
first = content.to_s.split(/\r?\n/).first.to_s
|
|
95
|
+
collapsed = first.gsub(/\s+/, " ").strip
|
|
96
|
+
collapsed.length <= SUMMARY_LIMIT ? collapsed : "#{collapsed[0, SUMMARY_LIMIT]}…"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/insika/tool_store.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Insika
|
|
|
9
9
|
# McpStore masks the env): headers whose name is in `secret_headers` never
|
|
10
10
|
# leave in plaintext to the UI — they become the sentinel `__OCULTO__`. Only `get_raw`/
|
|
11
11
|
# `all_raw` (consumed by DataDefinedTool/overlay, never the screen) return the
|
|
12
|
-
# real values
|
|
12
|
+
# real values.,.
|
|
13
13
|
#
|
|
14
14
|
# Record in the ConfigStore:
|
|
15
15
|
# { "definition" => { ...ToolDefinition#to_h... },
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
|
-
# Per-session TOOL-CALL trace, for debugging in the Studio
|
|
6
|
+
# Per-session TOOL-CALL trace, for debugging in the Studio. One
|
|
7
7
|
# record per session in the raw backend (scope "tool_traces") — RUNTIME data, next
|
|
8
8
|
# to sessions/tasks, NOT config (hence the raw `store:`, like SessionStore,
|
|
9
9
|
# not the ConfigStore). A capped LIST of entries; ToolEnvelope writes one
|
|
@@ -5,7 +5,7 @@ require "async/semaphore"
|
|
|
5
5
|
|
|
6
6
|
module Insika
|
|
7
7
|
module Tools
|
|
8
|
-
# Fan-out INSIDE a single tool (
|
|
8
|
+
# Fan-out INSIDE a single tool (§): when a tool must gather N
|
|
9
9
|
# independent I/O calls (stock + price + promo + delivery) into ONE result,
|
|
10
10
|
# `gather` runs them CONCURRENTLY on the turn's reactor and returns their values
|
|
11
11
|
# IN ORDER. Because a data-tool spends its time on the HTTP wait, those waits
|
|
@@ -13,7 +13,7 @@ module Insika
|
|
|
13
13
|
#
|
|
14
14
|
# This is the "aggregator tool" pattern: the model makes ONE tool call and the
|
|
15
15
|
# concurrency is an implementation detail of that tool — no change to the agent
|
|
16
|
-
# loop, no dependency on parallel tool execution
|
|
16
|
+
# loop, no dependency on parallel tool execution. Use it in a custom
|
|
17
17
|
# Ruby tool's #execute:
|
|
18
18
|
#
|
|
19
19
|
# def execute(store_id:)
|
|
@@ -10,7 +10,6 @@ module Insika
|
|
|
10
10
|
# ToolDefinition (the same pattern as A2ARemote). It makes an HTTP call described
|
|
11
11
|
# in config — no Ruby code per tool. Since it inherits RubyLLM::Tool (pulls in the gem),
|
|
12
12
|
# it is NOT required in lib/insika.rb; the overlay loads it lazily at registration
|
|
13
|
-
# (Step B). Phase 5, Step A.
|
|
14
13
|
#
|
|
15
14
|
# Contract preserved by duck-typing: it overrides name/description/parameters/
|
|
16
15
|
# execute; RubyLLM's params_schema derives from #parameters automatically.
|
|
@@ -28,7 +27,7 @@ module Insika
|
|
|
28
27
|
super()
|
|
29
28
|
end
|
|
30
29
|
|
|
31
|
-
# Turn context
|
|
30
|
+
# Turn context: the registry tool does NOT receive TurnState,
|
|
32
31
|
# so the Executor DEPOSITS the turn ids here, per-turn
|
|
33
32
|
# (chat/agent/tenant/store). They resolve {{ctx.*}} — SEPARATE from the model's
|
|
34
33
|
# {{param}} — to emit X-Chat-Id/X-Store-Id/X-Agent-Id. They come from the TURN, never from
|
|
@@ -46,8 +45,8 @@ module Insika
|
|
|
46
45
|
|
|
47
46
|
# FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
|
|
48
47
|
# the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
|
|
49
|
-
# params_schema; parameters is just a fallback). Provider-agnostic
|
|
50
|
-
# the only form that expresses nesting (object/array/enum)
|
|
48
|
+
# params_schema; parameters is just a fallback). Provider-agnostic and
|
|
49
|
+
# the only form that expresses nesting (object/array/enum).,.
|
|
51
50
|
def params_schema = @definition.parameters
|
|
52
51
|
|
|
53
52
|
# FLAT top-level view for discovery (tool_search calls #parameters on the resolved
|
|
@@ -121,7 +120,7 @@ module Insika
|
|
|
121
120
|
end
|
|
122
121
|
|
|
123
122
|
# ctx.* -> TURN context (deposited by the Executor); the rest -> MODEL args
|
|
124
|
-
# (kwargs). The split is the
|
|
123
|
+
# (kwargs). The split is the/R2 trust boundary: the model does
|
|
125
124
|
# not choose which chat/store the tool accesses.
|
|
126
125
|
def resolve(name, kwargs)
|
|
127
126
|
prefix = Insika::ToolDefinition::CTX_PREFIX
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ruby_llm"
|
|
4
|
+
require "time"
|
|
4
5
|
|
|
5
6
|
module Insika
|
|
6
7
|
module Tools
|
|
@@ -22,19 +23,76 @@ module Insika
|
|
|
22
23
|
# `param :name` (verified: the param is still present).
|
|
23
24
|
def name = "load_skill"
|
|
24
25
|
|
|
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)
|
|
26
35
|
@catalog = catalog
|
|
27
36
|
@allowed = Array(allowed_names).map(&:to_s)
|
|
37
|
+
@trace_recorder = trace_recorder
|
|
38
|
+
@state = state
|
|
39
|
+
@agent = agent
|
|
28
40
|
super()
|
|
29
41
|
end
|
|
30
42
|
|
|
31
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)
|
|
32
53
|
return { error: "skill '#{name}' not available for this agent" } unless @allowed.include?(name.to_s)
|
|
33
54
|
|
|
34
|
-
skill = @catalog.find(name)
|
|
55
|
+
skill = @catalog.find(name, agent: @agent)
|
|
35
56
|
return { error: "skill '#{name}' not found" } unless skill
|
|
36
57
|
|
|
37
|
-
skill
|
|
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
|
|
38
96
|
end
|
|
39
97
|
end
|
|
40
98
|
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
|
|
@@ -5,18 +5,18 @@ require_relative "agent_enum"
|
|
|
5
5
|
|
|
6
6
|
module Insika
|
|
7
7
|
module Tools
|
|
8
|
-
# In-process delegation to a CHILD agent
|
|
8
|
+
# In-process delegation to a CHILD agent — the Flue
|
|
9
9
|
# `session.task()` primitive. A system tool (like remember/load_skill): wired
|
|
10
10
|
# by the ChatBuilder ONLY when `profile.subagents` is present, so `require
|
|
11
11
|
# "ruby_llm"` stays in this file (loaded lazily in create_chat). NOT enveloped
|
|
12
12
|
# (system tool) — in the synchronous mode the child lives in the parent's
|
|
13
|
-
# envelope and re-runs on the parent's resume
|
|
13
|
+
# envelope and re-runs on the parent's resume.
|
|
14
14
|
#
|
|
15
15
|
# It holds no delegation logic itself: `execute` reads the parent TurnState
|
|
16
16
|
# (the subagents allowlist + resolved model for inheritance + depth) and hands
|
|
17
17
|
# off to `Executor#run_subagent`, which spawns the isolated child turn and
|
|
18
18
|
# returns its result. The child result = its text + the linked child session
|
|
19
|
-
# id (
|
|
19
|
+
# id (R3).
|
|
20
20
|
class Subagent < RubyLLM::Tool
|
|
21
21
|
description "Delegates a self-contained task to a specialized child agent. " \
|
|
22
22
|
"The child runs in an ISOLATED context (it does not see this " \
|
|
@@ -67,7 +67,7 @@ module Insika
|
|
|
67
67
|
return { dispatched: true, agent: result[:agent], session_id: result[:session_id] } if result[:dispatched]
|
|
68
68
|
|
|
69
69
|
# sync: link the child session id alongside the text so a multi-step parent
|
|
70
|
-
# can reference it and the transcript stays auditable (
|
|
70
|
+
# can reference it and the transcript stays auditable (R3).
|
|
71
71
|
{ text: result[:text], session_id: result[:session_id] }
|
|
72
72
|
end
|
|
73
73
|
end
|
|
@@ -5,7 +5,7 @@ require_relative "agent_enum"
|
|
|
5
5
|
|
|
6
6
|
module Insika
|
|
7
7
|
module Tools
|
|
8
|
-
# PARALLEL fan-out of child agents
|
|
8
|
+
# PARALLEL fan-out of child agents: delegate N self-contained
|
|
9
9
|
# tasks at once and get all answers back together, in ONE parent turn. Sibling
|
|
10
10
|
# of `spawn_subagent` (single); this one always sync-joins — the children run
|
|
11
11
|
# concurrently (their provider waits overlap on the reactor) and the combined
|
data/lib/insika/turn_output.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Insika
|
|
|
25
25
|
# deliberate:
|
|
26
26
|
#
|
|
27
27
|
# · The customer-visible stream is per MESSAGE, not per token. `ttft_ms` still
|
|
28
|
-
# measures the provider's first token, so
|
|
28
|
+
# measures the provider's first token, so's baselines stay comparable;
|
|
29
29
|
# what moved is when the customer can read it. For the WhatsApp edge this
|
|
30
30
|
# changes nothing — the dispatcher accumulated the deltas into one message
|
|
31
31
|
# anyway — and the Studio keeps its live typing off `:intermediate`.
|
|
@@ -44,7 +44,7 @@ module Insika
|
|
|
44
44
|
# decides and publishes once, at the end of the stage.
|
|
45
45
|
attr_reader :candidate
|
|
46
46
|
|
|
47
|
-
# filter: Safety::OutputFilter | nil (
|
|
47
|
+
# filter: Safety::OutputFilter | nil (nil = stream untouched).
|
|
48
48
|
# emit: ->(type, data) — the Executor's emitter, already bound to the task.
|
|
49
49
|
# public_intermediate: the agent opted this channel in (`edge_stream`), so the
|
|
50
50
|
# narration is TAGGED and `/v1/responses` gives it its own frame. Default false:
|
data/lib/insika/turn_state.rb
CHANGED
|
@@ -11,11 +11,11 @@ module Insika
|
|
|
11
11
|
:allowed_skills,
|
|
12
12
|
:chat, # the turn's RubyLLM::Chat instance
|
|
13
13
|
:session, # the turn's SessionStore::Session | nil (set at stage 2;
|
|
14
|
-
# read by create_chat for the per-chat model pin
|
|
15
|
-
:model_selection, # resolved ModelSelection
|
|
14
|
+
# read by create_chat for the per-chat model pin)
|
|
15
|
+
:model_selection, # resolved ModelSelection: model/provider/source/
|
|
16
16
|
# pinned/params/fallbacks. Set at stage 5; surfaced in usage.
|
|
17
17
|
:halt_reason, # set by Middleware when short-circuiting (halt-as-FAILURE)
|
|
18
|
-
:halt_response, # set by a Middleware for the GRACEFUL halt
|
|
18
|
+
:halt_response, # set by a Middleware for the GRACEFUL halt:
|
|
19
19
|
# the safe reply the turn completes with, WITHOUT touching the
|
|
20
20
|
# LLM. Distinct from halt_reason — a completion, not a failure.
|
|
21
21
|
:guardrail_block, # audit metadata the guardrail sets alongside halt_response
|
|
@@ -25,7 +25,11 @@ module Insika
|
|
|
25
25
|
# (after_task); the Executor emits one :guardrail_flagged each.
|
|
26
26
|
:response_content, # the turn's final assistant text, set at stage 6/on halt so the
|
|
27
27
|
# after_task validator can inspect it.
|
|
28
|
-
:output_filter
|
|
28
|
+
:output_filter, # per-turn Safety::OutputFilter (nil = off); redacts the stream.
|
|
29
|
+
:stuck_outcome # set by the signal_stuck system tool (WS5):
|
|
30
|
+
# { reason:, message: } when the agent declared it cannot
|
|
31
|
+
# proceed. The Executor tags the terminal event with
|
|
32
|
+
# outcome: "stuck" and emits :turn_stuck. nil = normal turn.
|
|
29
33
|
|
|
30
34
|
# Internal (not part of the contract): per-CALL correlation between RubyLLM's
|
|
31
35
|
# tool callbacks and the tool decorators — `current_tool_call` keys the
|
|
@@ -34,7 +38,7 @@ module Insika
|
|
|
34
38
|
#
|
|
35
39
|
# They live in FIBER STORAGE, not in ivars, and that is the whole point:
|
|
36
40
|
# `before_tool_call` → `tool.call` → `after_tool_result` all run in the SAME
|
|
37
|
-
# fiber, and with `ToolConcurrency`
|
|
41
|
+
# fiber, and with `ToolConcurrency` there is one fiber PER CALL. A
|
|
38
42
|
# single slot on this shared object would let one in-flight call overwrite
|
|
39
43
|
# another's — a side-effect recorded under the wrong id (so a resume skips the
|
|
40
44
|
# wrong tool, or re-runs a non-idempotent one) and a mislabelled event. Both
|
|
@@ -56,7 +60,7 @@ module Insika
|
|
|
56
60
|
Fiber[NAME_KEY] = name
|
|
57
61
|
end
|
|
58
62
|
|
|
59
|
-
# Internal (
|
|
63
|
+
# Internal (R1): the chat's message count RIGHT AFTER `assemble` (seeded
|
|
60
64
|
# history) and BEFORE `ask`. persist_turn slices `chat.messages.drop(baseline)`
|
|
61
65
|
# to serialize the turn's real exchange — user + assistant(tool_calls) + tool
|
|
62
66
|
# results + final assistant — into the transcript. nil = no chat recorded
|
|
@@ -70,13 +74,13 @@ module Insika
|
|
|
70
74
|
# capability_registry or empty profile.capabilities (parity).
|
|
71
75
|
attr_accessor :capability_names
|
|
72
76
|
|
|
73
|
-
# Internal
|
|
77
|
+
# Internal: the turn's resolved QueuePolicy. Read at stage 6 to decide
|
|
74
78
|
# whether this run accepts steered messages, and how they are worded. Resolved once
|
|
75
79
|
# per turn, in build_turn_state — an edit to the agent mid-run does not change the
|
|
76
80
|
# rules the run started under.
|
|
77
81
|
attr_accessor :queue_policy
|
|
78
82
|
|
|
79
|
-
# Internal
|
|
83
|
+
# Internal: true when this turn re-enters the pipeline via
|
|
80
84
|
# resume_task/recovery. The EdgeLimiter reads it to NEVER re-count or block a
|
|
81
85
|
# turn that was already admitted — a crash/pause under a saturated window must
|
|
82
86
|
# not swallow a legitimate message with the rate-limit reply.
|
|
@@ -86,13 +90,13 @@ module Insika
|
|
|
86
90
|
# (`remember` tool). Set in run_pipeline; nil = DEFAULT_TENANT in the MemoryStore.
|
|
87
91
|
attr_accessor :tenant
|
|
88
92
|
|
|
89
|
-
# Internal
|
|
93
|
+
# Internal: turn context deposited into the data-tools to
|
|
90
94
|
# resolve {{ctx.*}} (chat_id/agent_id/tenant/store_id) and emit
|
|
91
95
|
# X-Chat-Id/X-Store-Id/X-Agent-Id. A Hash of symbols, set in run_pipeline.
|
|
92
96
|
# Comes from the TURN, never from the model's args (R2). Distinct from `tenant` (memory).
|
|
93
97
|
attr_accessor :turn_context
|
|
94
98
|
|
|
95
|
-
# Internal (
|
|
99
|
+
# Internal (observability): the turn's token usage (input/output/
|
|
96
100
|
# total/cached + model), captured from the provider's response at stage 6. Goes
|
|
97
101
|
# to the terminal event (:task_completed) — feeds the usage of
|
|
98
102
|
# /v1/responses and the Telemetry (OTEL). nil = turn with no model response
|
|
@@ -111,19 +115,19 @@ module Insika
|
|
|
111
115
|
# by the coordinator for await(:approval)).
|
|
112
116
|
attr_accessor :requires_approval, :approval_coordinator, :actor
|
|
113
117
|
|
|
114
|
-
# Internal
|
|
118
|
+
# Internal: the turn's shared in-flight cap for tool calls —
|
|
115
119
|
# ONE Async::Semaphore(tool_concurrency), installed by ToolAssembly#wrap_tools
|
|
116
120
|
# and acquired by every ToolEnvelope, INCLUDING the ones tool_search promotes
|
|
117
121
|
# mid-turn (they read it off the state, so the cap survives promotion).
|
|
118
122
|
# nil = concurrency off: no gate, no overhead, serial execution unchanged.
|
|
119
123
|
attr_accessor :tool_gate
|
|
120
124
|
|
|
121
|
-
#
|
|
125
|
+
# parallel tool calls, resolved PER TURN and read by ChatBuilder
|
|
122
126
|
# (whether to hand the gem `concurrency:`) and ToolAssembly (the gate's size).
|
|
123
127
|
#
|
|
124
128
|
# `requested_tool_concurrency` is what the operator configured;
|
|
125
129
|
# `tool_concurrency` is what this turn actually gets. They differ for exactly
|
|
126
|
-
# one reason
|
|
130
|
+
# one reason —: `Executor#request_approval` blocks on `actor.await(approval)`,
|
|
127
131
|
# and the mailbox is one queue per TASK. Two fibers waiting there share it,
|
|
128
132
|
# `dequeue` wakes exactly one, the message is consumed, and the other fiber
|
|
129
133
|
# hangs until `approval_timeout` (~1h). So a turn that can suspend for a human
|
data/lib/insika/turn_timing.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
# Opt-in per-turn latency breakdown (
|
|
4
|
+
# Opt-in per-turn latency breakdown (locate the
|
|
5
5
|
# TTFB cost with real-turn data). OFF unless INSIKA_TURN_TIMING is set — when
|
|
6
6
|
# off the Executor never allocates one and the hot path pays only `nil&.mark`.
|
|
7
7
|
#
|
|
@@ -16,7 +16,7 @@ module Insika
|
|
|
16
16
|
#
|
|
17
17
|
# `ttft_ms` is the PROVIDER's first token, not the first byte the customer can
|
|
18
18
|
# read: TurnOutput publishes a message once it ends, so the customer-visible
|
|
19
|
-
# answer lands inside `gen_ms`. Measuring the provider is the point —
|
|
19
|
+
# answer lands inside `gen_ms`. Measuring the provider is the point —'s
|
|
20
20
|
# baselines (~720 ms, provider-bound) stay comparable across that change.
|
|
21
21
|
class TurnTiming
|
|
22
22
|
# EnvSchema owns "is this flag on?" (1/true/yes/on) — the same predicate that
|
data/lib/insika/usage_ledger.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
# Fixed-window counters on the KV Store
|
|
4
|
+
# Fixed-window counters on the KV Store: the durable side of
|
|
5
5
|
# the edge limits. One scope, keys shaped "kind:id:window_start" — the window
|
|
6
6
|
# start bucketed on the epoch keeps every process/worker on the SAME bucket
|
|
7
7
|
# without coordination (the Store's transaction serializes the read-modify-write,
|
data/lib/insika/version.rb
CHANGED