insika 0.3.0 → 0.8.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 +296 -0
- data/README.md +48 -12
- data/bin/insika +725 -0
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +116 -406
- data/docs/API.md +5 -5
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +137 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +14 -14
- data/docs/CONTEXT.md +63 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +87 -10
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +128 -3
- data/docs/FACTS.md +3 -3
- data/docs/HARVEST.md +5 -6
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +17 -29
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +46 -12
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +222 -0
- data/docs/REFINEMENT.md +14 -9
- data/docs/RELEASING.md +4 -4
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +5 -5
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +23 -7
- data/docs/SKILLS.md +11 -2
- data/docs/SOAK.md +3 -3
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +176 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +4 -4
- data/docs/improve.md +20 -0
- data/docs/index.md +8 -5
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/channel_delivery.rb +1 -1
- data/lib/insika/chat_builder.rb +50 -19
- data/lib/insika/commands/agent_payload.rb +2 -2
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +15 -3
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/compaction.rb +196 -0
- data/lib/insika/context/builder.rb +6 -2
- data/lib/insika/context/fragment.rb +4 -1
- data/lib/insika/context/priority.rb +8 -0
- data/lib/insika/context/providers/briefing.rb +53 -24
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/prompt.rb +30 -24
- data/lib/insika/context/providers/session.rb +46 -10
- data/lib/insika/context_trace_store.rb +11 -1
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/doctor.rb +280 -17
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +64 -79
- data/lib/insika/dsl/server_boot.rb +23 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +103 -2
- data/lib/insika/env_schema.rb +21 -7
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +11 -0
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +84 -2
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/executor.rb +295 -55
- data/lib/insika/followup_policy.rb +2 -25
- data/lib/insika/golden_store.rb +16 -1
- data/lib/insika/grounding/matcher.rb +1 -1
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/loop_detector.rb +5 -34
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +115 -31
- data/lib/insika/message_origin.rb +1 -1
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/onboarding.rb +17 -1
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +2 -2
- data/lib/insika/profile_source.rb +15 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/retention.rb +36 -1
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/server/app.rb +105 -15
- data/lib/insika/server/rack_app.rb +5 -1
- data/lib/insika/server/responses.rb +5 -5
- data/lib/insika/session_store.rb +34 -4
- data/lib/insika/settings_store.rb +8 -1
- data/lib/insika/skill_catalog.rb +12 -0
- data/lib/insika/soak/runner.rb +4 -4
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/studio/app.rb +591 -47
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +21 -21
- data/lib/insika/studio/forms.rb +57 -5
- data/lib/insika/studio/nav_icons.rb +14 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +61 -820
- data/lib/insika/studio/views/agents.erb +70 -57
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/evals.erb +2 -2
- data/lib/insika/studio/views/facts.erb +1 -1
- data/lib/insika/studio/views/funnel.erb +1 -1
- data/lib/insika/studio/views/home.erb +106 -67
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +14 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/session.erb +231 -177
- data/lib/insika/studio/views/settings.erb +50 -1
- data/lib/insika/studio/views/skills.erb +1 -1
- data/lib/insika/studio/views/tools.erb +24 -9
- data/lib/insika/telemetry/recorder.rb +49 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +47 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +68 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/tick.rb +24 -12
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_batch.rb +67 -0
- data/lib/insika/tool_usage_report.rb +162 -0
- data/lib/insika/tools/generate_image.rb +52 -7
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/turn_budget.rb +91 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +15 -4
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +184 -12
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +64 -0
- metadata +109 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Template gallery: example agents shipped INSIDE the gem
|
|
7
|
+
# (`lib/insika/templates/<name>/{agent.rb,README.md}`), one DSL file per
|
|
8
|
+
# template that is BOTH doors — `insika new <name>` copies it for the user
|
|
9
|
+
# to run and edit, and this module `evaluate`s the same file to hand its
|
|
10
|
+
# pack(s) to the Studio's "New from template" gallery. No parallel pack
|
|
11
|
+
# format to drift.
|
|
12
|
+
#
|
|
13
|
+
# A template's `agent.rb` guards its CLI demo footer with
|
|
14
|
+
# `if __FILE__ == $PROGRAM_NAME` (false when this module evaluates it) and
|
|
15
|
+
# ends with the bare `Insika.agent`/`Insika.system` result as its LAST
|
|
16
|
+
# expression, so `evaluate` gets it back as the string-eval's return value
|
|
17
|
+
# — no registration call, no second source of truth.
|
|
18
|
+
module Templates
|
|
19
|
+
ROOT = File.expand_path("templates", __dir__)
|
|
20
|
+
|
|
21
|
+
Entry = Data.define(:name, :title, :trail, :description, :capabilities, :studio, :env, :requires) do
|
|
22
|
+
def studio? = studio
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# -> [String] template dirs that have an agent.rb, lexicographic.
|
|
28
|
+
def names
|
|
29
|
+
return [] unless Dir.exist?(ROOT)
|
|
30
|
+
|
|
31
|
+
Dir.children(ROOT).select { |n| File.file?(agent_path(n)) }.sort
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# -> [Entry] every template, parsed metadata only (no evaluation — cheap,
|
|
35
|
+
# safe to call on every render of the Studio gallery).
|
|
36
|
+
def all = names.map { |n| read(n) }
|
|
37
|
+
|
|
38
|
+
# -> Entry for one template. Raises NotFoundError for an unknown name —
|
|
39
|
+
# same discipline as a missing agent/MCP instance.
|
|
40
|
+
def read(name)
|
|
41
|
+
path = agent_path(name)
|
|
42
|
+
raise Insika::NotFoundError, "template '#{name}' not found" unless File.file?(path)
|
|
43
|
+
|
|
44
|
+
meta = frontmatter(File.read(path))
|
|
45
|
+
Entry.new(
|
|
46
|
+
name: name.to_s, title: presence(meta["title"]) || name.to_s, trail: presence(meta["trail"]),
|
|
47
|
+
description: meta["description"].to_s,
|
|
48
|
+
capabilities: split_list(meta["capabilities"]),
|
|
49
|
+
studio: meta.fetch("studio", true) != false,
|
|
50
|
+
env: split_list(meta["env"]), requires: presence(meta["requires"])
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Evaluates the template's agent.rb in an ISOLATED scope (a fresh Object's
|
|
55
|
+
# instance_eval) and returns whatever its last expression is — the built
|
|
56
|
+
# `Insika::DSL::Definition` or `Insika::DSL::System`. $PROGRAM_NAME here is
|
|
57
|
+
# whatever process called this (rspec, the CLI, the Studio server), never
|
|
58
|
+
# this file's path, so the template's own `if __FILE__ == $PROGRAM_NAME`
|
|
59
|
+
# demo footer never runs: no network call, no ARGV parsing, no puts.
|
|
60
|
+
#
|
|
61
|
+
# The fresh-Object receiver keeps a template's local variables and `def`s
|
|
62
|
+
# from leaking into the next one evaluated in the same process; a
|
|
63
|
+
# top-level CONSTANT would still leak (Ruby scopes constant assignment
|
|
64
|
+
# lexically, not by `self`) — wave-1 templates simply don't declare any
|
|
65
|
+
# (the conformance spec, would catch a future one that did).
|
|
66
|
+
def evaluate(name)
|
|
67
|
+
path = agent_path(name)
|
|
68
|
+
raise Insika::NotFoundError, "template '#{name}' not found" unless File.file?(path)
|
|
69
|
+
|
|
70
|
+
Object.new.instance_eval(File.read(path), path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# -> [Pack] one per agent, regardless of whether the template is a single
|
|
74
|
+
# `Insika.agent` (Definition#to_pack) or a system (System#to_packs).
|
|
75
|
+
def packs_for(name)
|
|
76
|
+
built = evaluate(name)
|
|
77
|
+
built.respond_to?(:to_packs) ? built.to_packs : [built.to_pack]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def agent_path(name) = File.join(ROOT, name.to_s, "agent.rb")
|
|
81
|
+
def readme_path(name) = File.join(ROOT, name.to_s, "README.md")
|
|
82
|
+
|
|
83
|
+
# A `# ---` … `# ---` comment block at the very top of the file, YAML
|
|
84
|
+
# inside (each line stripped of its leading `# `). Not real Ruby
|
|
85
|
+
# frontmatter (there's no such thing) — a convention this module alone
|
|
86
|
+
# parses, so the metadata lives in the one file without needing a
|
|
87
|
+
# side-channel manifest.
|
|
88
|
+
def frontmatter(source)
|
|
89
|
+
lines = source.lines
|
|
90
|
+
# Every template starts with the same magic comment every other .rb
|
|
91
|
+
# file in the gem does — skip it (and any blank line) before looking
|
|
92
|
+
# for the block, so templates don't have to break that convention.
|
|
93
|
+
lines = lines.drop(1) while lines.first && (lines.first.strip.empty? || lines.first.strip == "# frozen_string_literal: true")
|
|
94
|
+
return {} unless lines.first&.strip == "# ---"
|
|
95
|
+
|
|
96
|
+
body = lines.drop(1)
|
|
97
|
+
.take_while { |l| l.strip != "# ---" }
|
|
98
|
+
.map { |l| l.sub(/\A#\s?/, "") }
|
|
99
|
+
.join
|
|
100
|
+
YAML.safe_load(body) || {}
|
|
101
|
+
end
|
|
102
|
+
private_class_method :frontmatter
|
|
103
|
+
|
|
104
|
+
def split_list(value)
|
|
105
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
106
|
+
end
|
|
107
|
+
private_class_method :split_list
|
|
108
|
+
|
|
109
|
+
def presence(str) = Insika::Coercion.presence(str)
|
|
110
|
+
private_class_method :presence
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/insika/tick.rb
CHANGED
|
@@ -3,21 +3,23 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
|
-
|
|
7
|
-
# pass does
|
|
6
|
+
# The periodic tick: durability stops waiting for a reboot. One
|
|
7
|
+
# pass does three things, in this order:
|
|
8
8
|
#
|
|
9
9
|
# 1. DRAIN the outbox (`ChannelDelivery#sweep`) — replies a previous pass
|
|
10
10
|
# (or process) recorded and never claimed. Ungated: every record carries
|
|
11
11
|
# its own transactional claim, so N workers draining is safe.
|
|
12
|
-
# 2.
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
12
|
+
# 2. The engine's background duties, each gated by its OWN claim window so
|
|
13
|
+
# their O(n) scans never ride the 60 s loop: retention (daily), the
|
|
14
|
+
# outcome fold, the follow-up firer, and the recurring-schedule firer
|
|
15
|
+
# (the engine's own cron — it superseded the "point your own cron at
|
|
16
|
+
# the route" decision, see docs/SCHEDULING.md).
|
|
17
|
+
# 3. SWEEP stale orphaned tasks (`Recovery#run(stale_after:)`) — gated by a
|
|
18
|
+
# bucketed claim, so exactly one worker per window sweeps. The staleness
|
|
19
|
+
# threshold is the liveness gate: a live :running turn is bounded by
|
|
20
|
+
# turn_timeout, so anything untouched past it cannot be alive.
|
|
17
21
|
#
|
|
18
|
-
# It is NOT a job queue: no schedules, no priorities, no fan-out.
|
|
19
|
-
# refinement hook once pictured here is dropped by merit —
|
|
20
|
-
# docs/REFINEMENT.md's "no scheduler in the engine" stands.
|
|
22
|
+
# It is NOT a job queue: no schedules queue, no priorities, no fan-out.
|
|
21
23
|
class Tick
|
|
22
24
|
# 60s: a customer waiting on WhatsApp is the deadline. 900s = 3x the
|
|
23
25
|
# default turn_timeout (300s) — the rule, not the number: the threshold
|
|
@@ -31,7 +33,8 @@ module Insika
|
|
|
31
33
|
|
|
32
34
|
def initialize(store:, recovery:, channel_delivery:, logger: nil,
|
|
33
35
|
interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
|
|
34
|
-
sleeper: nil, retention: nil, funnel: nil, followup: nil
|
|
36
|
+
sleeper: nil, retention: nil, funnel: nil, followup: nil,
|
|
37
|
+
schedule: nil)
|
|
35
38
|
@store = store
|
|
36
39
|
@recovery = recovery
|
|
37
40
|
@channel_delivery = channel_delivery
|
|
@@ -39,9 +42,10 @@ module Insika
|
|
|
39
42
|
@interval = interval.to_i
|
|
40
43
|
@stale_after = stale_after.to_i
|
|
41
44
|
@sleeper = sleeper || method(:default_sleep)
|
|
42
|
-
@retention = retention #
|
|
45
|
+
@retention = retention # the daily age-based sweep; nil = none
|
|
43
46
|
@funnel = funnel # the tick-driven outcome fold; nil = none
|
|
44
47
|
@followup = followup # the tick-driven follow-up firer; nil = none
|
|
48
|
+
@schedule = schedule # the recurring-schedule firer; nil = none
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
# the fold is wired after the Tick is built (the graph passes
|
|
@@ -53,6 +57,10 @@ module Insika
|
|
|
53
57
|
# shape as `funnel` — the stores come from the spine).
|
|
54
58
|
attr_accessor :followup
|
|
55
59
|
|
|
60
|
+
# the recurring-schedule firer, wired after the Tick is built
|
|
61
|
+
# (same shape — the stores come from the spine).
|
|
62
|
+
attr_accessor :schedule
|
|
63
|
+
|
|
56
64
|
def enabled? = @interval.positive?
|
|
57
65
|
|
|
58
66
|
# One pass, pure (no reactor needed): the serving loop calls it on a timer,
|
|
@@ -73,6 +81,10 @@ module Insika
|
|
|
73
81
|
# OWN claim window so the O(n) scans never ride the 60 s loop.
|
|
74
82
|
followup_summary = @followup&.run
|
|
75
83
|
summary[:followup] = followup_summary if followup_summary
|
|
84
|
+
# the recurring-schedule firer — the tick's fourth duty,
|
|
85
|
+
# the same claim-window discipline as the follow-up firer.
|
|
86
|
+
schedule_summary = @schedule&.run
|
|
87
|
+
summary[:schedule] = schedule_summary if schedule_summary
|
|
76
88
|
return summary unless claim_window
|
|
77
89
|
|
|
78
90
|
result = @recovery.run(stale_after: @stale_after)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# IANA timezone handling through the OS tz database — the
|
|
5
|
+
# engine's only route to a zone NAME (Ruby stdlib's `Time#getlocal` takes an
|
|
6
|
+
# offset, not a zone name). Shared by FollowupPolicy (quiet hours), the cron
|
|
7
|
+
# parser (next-fire materialization) and the doctor (zone existence).
|
|
8
|
+
#
|
|
9
|
+
# IANA names are resolved by pointing Ruby's `TZ` at the zone for the
|
|
10
|
+
# computation. Save/restore keeps the global intact; under the engine's
|
|
11
|
+
# cooperative fiber model — no IO between the save and the restore — the
|
|
12
|
+
# mutation is atomic on the calling fiber.
|
|
13
|
+
module Timezone
|
|
14
|
+
# The candidate tz-data roots (TZDIR first — Ruby's own lookup env). The
|
|
15
|
+
# zone name maps to a FILE under the root ("America/Sao_Paulo" ->
|
|
16
|
+
# "America/Sao_Paulo").
|
|
17
|
+
TZ_ROOTS = ([ENV["TZDIR"]] +
|
|
18
|
+
%w[/usr/share/zoneinfo /usr/share/lib/zoneinfo /etc/zoneinfo])
|
|
19
|
+
.compact.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# -> bool: is `zone` an IANA name the OS tz database knows? A bogus zone
|
|
24
|
+
# is a malformed declaration — refused where the doctor can name it (an
|
|
25
|
+
# unknown ENV["TZ"] silently behaves as UTC, so existence is checked
|
|
26
|
+
# against the database, not by asking Time).
|
|
27
|
+
def known?(zone)
|
|
28
|
+
zone = zone.to_s
|
|
29
|
+
return true if zone == "UTC" || zone == "Etc/UTC"
|
|
30
|
+
|
|
31
|
+
TZ_ROOTS.any? { |root| File.directory?(root) && File.exist?(File.join(root, zone)) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Yields `time` interpreted in the given IANA zone (via a save/restore of
|
|
35
|
+
# ENV["TZ"] — the stdlib-only route to the OS tz database). Returns the
|
|
36
|
+
# block's value.
|
|
37
|
+
def in_zone(zone, time)
|
|
38
|
+
previous = ENV["TZ"]
|
|
39
|
+
ENV["TZ"] = zone.to_s
|
|
40
|
+
yield time
|
|
41
|
+
ensure
|
|
42
|
+
ENV["TZ"] = previous
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# The batch arithmetic behind every mid-turn `user` append.
|
|
5
|
+
#
|
|
6
|
+
# A model step that calls tools produces ONE assistant message announcing N
|
|
7
|
+
# tool calls, followed by N `role: tool` messages. Anthropic rejects a `user`
|
|
8
|
+
# message that lands between two of those results outright, so the ONLY valid
|
|
9
|
+
# append point inside a turn is the instant the Nth result closes the batch.
|
|
10
|
+
# SteerInjector discovered this rule; LoopDetector and TurnBudget both live by
|
|
11
|
+
# it, which is why the counting lives here instead of twice.
|
|
12
|
+
#
|
|
13
|
+
# Not a general-purpose helper: it answers one question ("did a batch just
|
|
14
|
+
# close?") and remembers one fact ("was this batch halted"), because a
|
|
15
|
+
# `halt_when` batch has no next model step and anything appended there would
|
|
16
|
+
# sit unread forever.
|
|
17
|
+
class ToolBatch
|
|
18
|
+
def initialize
|
|
19
|
+
@expected = nil # tool calls announced by the batch in flight (nil = none)
|
|
20
|
+
@seen = 0
|
|
21
|
+
@halted = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Feeds a RubyLLM message (duck-typed). True EXACTLY on the message that
|
|
25
|
+
# closes a batch of tool calls — the append boundary. Everything else,
|
|
26
|
+
# including the assistant message that opens the batch, is false.
|
|
27
|
+
def closed?(message)
|
|
28
|
+
role = field(message, :role).to_s
|
|
29
|
+
return open(message) if role == "assistant"
|
|
30
|
+
return false unless role == "tool" && @expected
|
|
31
|
+
|
|
32
|
+
@seen += 1
|
|
33
|
+
return false if @seen < @expected
|
|
34
|
+
|
|
35
|
+
@expected = nil
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# From after_tool_result, with the RAW result: a Tool::Halt is only
|
|
40
|
+
# recognizable there.
|
|
41
|
+
def halt!(result)
|
|
42
|
+
@halted = true if defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def halted? = @halted
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# An assistant message with no tool calls is the model TALKING: the turn is
|
|
50
|
+
# ending, so nothing is in flight any more.
|
|
51
|
+
def open(message)
|
|
52
|
+
calls = field(message, :tool_calls)
|
|
53
|
+
size = calls.respond_to?(:size) ? calls.size : 0
|
|
54
|
+
@expected = size.zero? ? nil : size
|
|
55
|
+
@seen = 0
|
|
56
|
+
@halted = false
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def field(message, name)
|
|
61
|
+
return message.public_send(name) if message.respond_to?(name)
|
|
62
|
+
return message[name] || message[name.to_s] if message.respond_to?(:[])
|
|
63
|
+
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# The tool AUDIT the per-session trace cannot answer: `tool_traces` records
|
|
7
|
+
# every call, but one session at a time, and nothing aggregates — so "which
|
|
8
|
+
# tools does this agent carry and never use?" had no surface at all. This
|
|
9
|
+
# report is that surface. Read-only by design: it names the candidates, the
|
|
10
|
+
# OPERATOR removes (a tool the report flags may still be the one a rare but
|
|
11
|
+
# critical flow needs).
|
|
12
|
+
#
|
|
13
|
+
# Attribution rides the task record: a session does not stamp its agent, but
|
|
14
|
+
# every task carries the agent in its command payload — tasks → sessions →
|
|
15
|
+
# tool_traces is the same read the Studio does. Three findings per agent:
|
|
16
|
+
#
|
|
17
|
+
# never_called — in `tools_allow`, zero calls in any stored trace. Dead
|
|
18
|
+
# weight: it costs schema tokens on every request and buys
|
|
19
|
+
# nothing (the pilot's `search_orders` is the known example).
|
|
20
|
+
# error_rate — called inside the window with > 30% conventional errors
|
|
21
|
+
# (the trace's own `ok` flag). Either the tool is broken or
|
|
22
|
+
# the model cannot hold its contract; both are operator work.
|
|
23
|
+
# stale — called at some point, but not once inside the window.
|
|
24
|
+
#
|
|
25
|
+
# Bounded and honest: it reads only what the stores kept (the trace caps at
|
|
26
|
+
# 200 entries/session), so a count here is "at least", never an exact total.
|
|
27
|
+
class ToolUsageReport
|
|
28
|
+
WINDOW_DAYS = 14
|
|
29
|
+
ERROR_RATE_THRESHOLD = 0.30
|
|
30
|
+
|
|
31
|
+
# One finding. kind: "never_called" | "error_rate" | "stale".
|
|
32
|
+
Row = Data.define(:agent, :tool, :kind, :detail) do
|
|
33
|
+
def to_h = { "agent" => agent, "tool" => tool, "kind" => kind, "detail" => detail }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Report = Data.define(:generated_at, :days, :agents, :rows) do
|
|
37
|
+
def to_h
|
|
38
|
+
{ "generated_at" => generated_at, "days" => days, "agents" => agents,
|
|
39
|
+
"rows" => rows.map(&:to_h) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Human report, grouped by agent. Silent agents still print their header —
|
|
43
|
+
# "nothing flagged" is a result, not an omission.
|
|
44
|
+
def to_s
|
|
45
|
+
lines = ["tool usage — last #{days} day(s), generated #{generated_at}"]
|
|
46
|
+
agents.each do |agent|
|
|
47
|
+
mine = rows.select { |r| r.agent == agent }
|
|
48
|
+
lines << "" << "#{agent}: #{mine.empty? ? 'nothing flagged' : "#{mine.length} finding(s)"}"
|
|
49
|
+
mine.each { |r| lines << format(" %-13s %s — %s", r.kind, r.tool, r.detail) }
|
|
50
|
+
end
|
|
51
|
+
lines.join("\n")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(task_store:, tool_trace_store:, profile_source:, now: nil)
|
|
56
|
+
@task_store = task_store
|
|
57
|
+
@tool_trace_store = tool_trace_store
|
|
58
|
+
@profile_source = profile_source
|
|
59
|
+
@now = now
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# -> Report. `agent:` narrows to one agent (must still be a stored profile).
|
|
63
|
+
def generate(days: WINDOW_DAYS, agent: nil)
|
|
64
|
+
now = @now || Time.now.utc
|
|
65
|
+
cutoff = now - (days * 24 * 60 * 60)
|
|
66
|
+
profiles = @profile_source.all_raw
|
|
67
|
+
profiles = profiles.select { |r| r["id"].to_s == agent.to_s } if agent
|
|
68
|
+
sessions = sessions_by_agent
|
|
69
|
+
|
|
70
|
+
rows = profiles.flat_map do |record|
|
|
71
|
+
id = record["id"].to_s
|
|
72
|
+
stats = tool_stats(sessions[id] || [], cutoff)
|
|
73
|
+
never_called_rows(id, record, stats) +
|
|
74
|
+
error_rate_rows(id, stats, days) +
|
|
75
|
+
stale_rows(id, stats)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Report.new(generated_at: now.iso8601, days: days,
|
|
79
|
+
agents: profiles.map { |r| r["id"].to_s }.sort,
|
|
80
|
+
rows: rows.sort_by { |r| [r.agent, r.kind, r.tool] }.freeze)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# agent id -> [session ids], via the task records (the only place a session
|
|
86
|
+
# is tied to its agent). A task without agent or session (operator commands,
|
|
87
|
+
# workflows without a chat) contributes nothing.
|
|
88
|
+
def sessions_by_agent
|
|
89
|
+
acc = Hash.new { |h, k| h[k] = [] }
|
|
90
|
+
@task_store.each_id do |task_id|
|
|
91
|
+
task = @task_store.find(task_id) or next
|
|
92
|
+
agent = task.command.is_a?(Hash) ? task.command.dig("payload", "agent") : nil
|
|
93
|
+
next if agent.to_s.empty? || task.session_id.to_s.empty?
|
|
94
|
+
|
|
95
|
+
acc[agent.to_s] << task.session_id
|
|
96
|
+
end
|
|
97
|
+
acc.transform_values(&:uniq)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# tool name -> { calls:, errors:, window_calls:, window_errors:, last_at: }
|
|
101
|
+
# over every stored trace entry of the agent's sessions.
|
|
102
|
+
def tool_stats(session_ids, cutoff)
|
|
103
|
+
stats = Hash.new { |h, k| h[k] = { calls: 0, errors: 0, window_calls: 0, window_errors: 0, last_at: nil } }
|
|
104
|
+
session_ids.each do |sid|
|
|
105
|
+
@tool_trace_store.for_session(sid).each do |entry|
|
|
106
|
+
s = stats[entry["tool"].to_s]
|
|
107
|
+
at = parse_time(entry["at"])
|
|
108
|
+
error = entry["ok"] == false
|
|
109
|
+
s[:calls] += 1
|
|
110
|
+
s[:errors] += 1 if error
|
|
111
|
+
s[:last_at] = at if at && (s[:last_at].nil? || at > s[:last_at])
|
|
112
|
+
next unless at && at >= cutoff
|
|
113
|
+
|
|
114
|
+
s[:window_calls] += 1
|
|
115
|
+
s[:window_errors] += 1 if error
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
stats
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def never_called_rows(agent, record, stats)
|
|
122
|
+
allow = record["tools_allow"]
|
|
123
|
+
return [] if allow.nil? # no allowlist declared -> nothing to audit against
|
|
124
|
+
|
|
125
|
+
Array(allow).map(&:to_s).reject { |t| stats.key?(t) }.map do |tool|
|
|
126
|
+
Row.new(agent: agent, tool: tool, kind: "never_called",
|
|
127
|
+
detail: "in tools_allow, never called in any stored trace — " \
|
|
128
|
+
"its schema still ships on every request")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def error_rate_rows(agent, stats, days)
|
|
133
|
+
stats.filter_map do |tool, s|
|
|
134
|
+
next if s[:window_calls].zero?
|
|
135
|
+
|
|
136
|
+
rate = s[:window_errors].to_f / s[:window_calls]
|
|
137
|
+
next if rate <= ERROR_RATE_THRESHOLD
|
|
138
|
+
|
|
139
|
+
Row.new(agent: agent, tool: tool, kind: "error_rate",
|
|
140
|
+
detail: "#{s[:window_errors]}/#{s[:window_calls]} call(s) errored in the last " \
|
|
141
|
+
"#{days} day(s) (#{(rate * 100).round}%)")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def stale_rows(agent, stats)
|
|
146
|
+
stats.filter_map do |tool, s|
|
|
147
|
+
next if s[:window_calls].positive? || s[:last_at].nil?
|
|
148
|
+
|
|
149
|
+
Row.new(agent: agent, tool: tool, kind: "stale",
|
|
150
|
+
detail: "last called #{s[:last_at].iso8601}, not once inside the window")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_time(value)
|
|
155
|
+
return nil if value.to_s.empty?
|
|
156
|
+
|
|
157
|
+
Time.parse(value.to_s).utc
|
|
158
|
+
rescue ArgumentError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -14,12 +14,42 @@ module Insika
|
|
|
14
14
|
# (`channel.capabilities` includes "image_output") — nothing leaks by
|
|
15
15
|
# default. The image is an envelope part, never part of the answer text;
|
|
16
16
|
# the provider's tokens are merged into the turn's usage like any ask.
|
|
17
|
+
#
|
|
18
|
+
# Also EDITS — `source_image_urls` (or, absent that, the turn's
|
|
19
|
+
# own inbound photo) rides `paint(with:)`; `mask_url` rides `paint(mask:)`.
|
|
20
|
+
# What the edit MEANS (a try-on, a mockup) is the skill's business; this
|
|
21
|
+
# tool only transports the bytes.
|
|
17
22
|
class GenerateImage < RubyLLM::Tool
|
|
18
|
-
description "Generate an image and attach it to the reply as an
|
|
19
|
-
"Use when the customer asked for a picture or
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
description "Generate an image, or EDIT one, and attach it to the reply as an " \
|
|
24
|
+
"output part. Use when the customer asked for a picture, or asked to " \
|
|
25
|
+
"transform/edit a photo (a virtual try-on, a mockup on their wall, a " \
|
|
26
|
+
"touch-up). Omitting source_image_urls generates a new image from the " \
|
|
27
|
+
"prompt alone — UNLESS this turn carries an inbound photo, in which case " \
|
|
28
|
+
"that photo is edited by default (pass source_image_urls explicitly to " \
|
|
29
|
+
"generate from scratch instead)."
|
|
30
|
+
# explicit JSON-schema form (the `param` DSL only reaches strings/scalars,
|
|
31
|
+
# and source_image_urls needs a typed array — the bare-array gotcha, #128).
|
|
32
|
+
params(
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
prompt: { type: "string", description: "What to draw, or what edit to make, in detail" },
|
|
36
|
+
size: { type: "string",
|
|
37
|
+
description: "Optional canvas size, e.g. 1024x1024 (default from the agent config)" },
|
|
38
|
+
source_image_urls: {
|
|
39
|
+
type: "array",
|
|
40
|
+
description: "Image URLs to edit instead of generating from scratch — e.g. " \
|
|
41
|
+
"the photo the customer just sent in this conversation " \
|
|
42
|
+
"({{ctx.image_url}}), or any other URL from this chat. Omit to use " \
|
|
43
|
+
"the turn's inbound photo by default (if any), or to generate a " \
|
|
44
|
+
"fresh image when there is none.",
|
|
45
|
+
items: { type: "string" }
|
|
46
|
+
},
|
|
47
|
+
mask_url: { type: "string",
|
|
48
|
+
description: "Optional mask image URL marking which area of the " \
|
|
49
|
+
"source(s) to edit (transparent = editable)" }
|
|
50
|
+
},
|
|
51
|
+
required: %w[prompt]
|
|
52
|
+
)
|
|
23
53
|
|
|
24
54
|
def name = "generate_image"
|
|
25
55
|
|
|
@@ -32,13 +62,28 @@ module Insika
|
|
|
32
62
|
super()
|
|
33
63
|
end
|
|
34
64
|
|
|
35
|
-
def execute(prompt:, size: nil)
|
|
36
|
-
cfg = @config.merge("size" => size.to_s
|
|
65
|
+
def execute(prompt:, size: nil, source_image_urls: nil, mask_url: nil)
|
|
66
|
+
cfg = @config.merge("size" => size.to_s, "mask_url" => mask_url.to_s)
|
|
67
|
+
.reject { |_, v| v.to_s.empty? }
|
|
68
|
+
cfg = cfg.merge(source_config(source_image_urls))
|
|
37
69
|
part, usage = @runner.generate_media_output(:image, prompt.to_s, cfg)
|
|
38
70
|
@state.output_parts << part
|
|
39
71
|
@runner.account_media_usage(@state, part, usage)
|
|
40
72
|
"image generated and attached to the reply (#{part["mime_type"]})"
|
|
41
73
|
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Explicit URLs win over the default; a turn with inbound images (no
|
|
78
|
+
# explicit URLs) hands `Output.generate_image` the ALREADY-FETCHED
|
|
79
|
+
# attachments (bypassing the URL fetch — they are bytes we hold).
|
|
80
|
+
def source_config(source_image_urls)
|
|
81
|
+
urls = Array(source_image_urls).map(&:to_s).reject(&:empty?)
|
|
82
|
+
return { "source_urls" => urls } if urls.any?
|
|
83
|
+
return {} unless Array(@state.image_attachments).any?
|
|
84
|
+
|
|
85
|
+
{ "source_attachments" => @state.image_attachments }
|
|
86
|
+
end
|
|
42
87
|
end
|
|
43
88
|
end
|
|
44
89
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Tools
|
|
8
|
+
# Level 2 of knowledge's progressive disclosure: loads a learned
|
|
9
|
+
# concept's full body on demand, by the name the `<knowledge>` block
|
|
10
|
+
# listed. Same shape as `LoadSkill` — a system default (outside the
|
|
11
|
+
# allowlist), wired only when the profile opted in
|
|
12
|
+
# (`knowledge.retrieve`), never through `tools_allow`.
|
|
13
|
+
#
|
|
14
|
+
# `require "ruby_llm"` stays in THIS file (it inherits from
|
|
15
|
+
# RubyLLM::Tool), so it does NOT enter lib/insika.rb — the Executor
|
|
16
|
+
# loads it lazily inside create_chat, same as load_skill.
|
|
17
|
+
class LoadKnowledge < RubyLLM::Tool
|
|
18
|
+
description "Loads the complete content of a learned concept by name"
|
|
19
|
+
param :name, desc: "Exact concept name, as listed in <knowledge>"
|
|
20
|
+
|
|
21
|
+
# RubyLLM::Tool#name derives from self.class.name — for a nested class it
|
|
22
|
+
# produces "insika--tools--load_knowledge", not "load_knowledge" (which
|
|
23
|
+
# wire_callbacks/:knowledge_retrieved assume). Explicit override, same
|
|
24
|
+
# reason LoadSkill has one.
|
|
25
|
+
def name = "load_knowledge"
|
|
26
|
+
|
|
27
|
+
# trace_recorder/state are OPTIONAL (nil = no trace, parity): this tool
|
|
28
|
+
# is deliberately NOT enveloped (ToolAssembly#wrap_tools), so without
|
|
29
|
+
# recording HERE the call is missing from the Studio's trace — same
|
|
30
|
+
# shape as LoadSkill.
|
|
31
|
+
def initialize(store, agent_id, tenant: nil, trace_recorder: nil, state: nil)
|
|
32
|
+
@store = store
|
|
33
|
+
@agent_id = agent_id
|
|
34
|
+
@tenant = tenant
|
|
35
|
+
@trace_recorder = trace_recorder
|
|
36
|
+
@state = state
|
|
37
|
+
super()
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def execute(name:)
|
|
41
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
result = load(name)
|
|
43
|
+
trace(name, result, started)
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def load(name)
|
|
50
|
+
content = @store.get(@agent_id, name.to_s, tenant: @tenant)
|
|
51
|
+
return { error: "concept '#{name}' not found" } unless content
|
|
52
|
+
|
|
53
|
+
content
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Mirrors LoadSkill#trace (same entry shape, so the Studio renders it
|
|
57
|
+
# like any other call). NEVER breaks the turn — the trace is
|
|
58
|
+
# observability, not the answer.
|
|
59
|
+
def trace(name, result, started)
|
|
60
|
+
return unless @trace_recorder && @state&.task&.session_id
|
|
61
|
+
|
|
62
|
+
@trace_recorder.record(
|
|
63
|
+
session_id: @state.task.session_id,
|
|
64
|
+
entry: { "turn" => @state.turn, "tool" => "load_knowledge", "call_id" => "",
|
|
65
|
+
"args" => { "name" => name.to_s }, "result" => result,
|
|
66
|
+
"ms" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
|
|
67
|
+
"at" => Time.now.utc.iso8601 }
|
|
68
|
+
)
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|