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.
Files changed (204) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -0
  3. data/README.md +48 -12
  4. data/bin/insika +725 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +116 -406
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +137 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +63 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +87 -10
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +17 -29
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +46 -12
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +222 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +5 -5
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +23 -7
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +3 -3
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +176 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +99 -17
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +50 -19
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/compaction.rb +196 -0
  76. data/lib/insika/context/builder.rb +6 -2
  77. data/lib/insika/context/fragment.rb +4 -1
  78. data/lib/insika/context/priority.rb +8 -0
  79. data/lib/insika/context/providers/briefing.rb +53 -24
  80. data/lib/insika/context/providers/knowledge.rb +108 -0
  81. data/lib/insika/context/providers/prompt.rb +30 -24
  82. data/lib/insika/context/providers/session.rb +46 -10
  83. data/lib/insika/context_trace_store.rb +11 -1
  84. data/lib/insika/cron.rb +189 -0
  85. data/lib/insika/demo/agent_attrs.rb +43 -0
  86. data/lib/insika/demo/golden_cases.rb +81 -0
  87. data/lib/insika/demo/seeder.rb +336 -0
  88. data/lib/insika/doctor.rb +280 -17
  89. data/lib/insika/dsl/definition.rb +3 -2
  90. data/lib/insika/dsl/runtime.rb +64 -79
  91. data/lib/insika/dsl/server_boot.rb +23 -1
  92. data/lib/insika/dsl/system.rb +10 -2
  93. data/lib/insika/dsl.rb +103 -2
  94. data/lib/insika/env_schema.rb +21 -7
  95. data/lib/insika/evals/golden.rb +41 -4
  96. data/lib/insika/evals/judge.rb +47 -2
  97. data/lib/insika/evals/pairwise.rb +11 -0
  98. data/lib/insika/evals/persona.rb +98 -0
  99. data/lib/insika/evals/runner.rb +9 -0
  100. data/lib/insika/evals/simulator.rb +225 -0
  101. data/lib/insika/evals/transport.rb +84 -2
  102. data/lib/insika/event_stream.rb +10 -0
  103. data/lib/insika/executor.rb +295 -55
  104. data/lib/insika/followup_policy.rb +2 -25
  105. data/lib/insika/golden_store.rb +16 -1
  106. data/lib/insika/grounding/matcher.rb +1 -1
  107. data/lib/insika/knowledge.rb +680 -0
  108. data/lib/insika/knowledge_store.rb +140 -0
  109. data/lib/insika/loop_detector.rb +5 -34
  110. data/lib/insika/mcp_client.rb +94 -0
  111. data/lib/insika/mcp_json.rb +74 -0
  112. data/lib/insika/mcp_live_tool.rb +43 -0
  113. data/lib/insika/mcp_store.rb +98 -26
  114. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  115. data/lib/insika/mcp_tool_registry.rb +100 -0
  116. data/lib/insika/media.rb +115 -31
  117. data/lib/insika/message_origin.rb +1 -1
  118. data/lib/insika/middleware.rb +9 -0
  119. data/lib/insika/onboarding.rb +17 -1
  120. data/lib/insika/outcome_store.rb +1 -1
  121. data/lib/insika/overlay_tool_registry.rb +37 -17
  122. data/lib/insika/packaging.rb +2 -2
  123. data/lib/insika/profile_source.rb +15 -1
  124. data/lib/insika/prompt_catalog.rb +10 -0
  125. data/lib/insika/retention.rb +36 -1
  126. data/lib/insika/router/app.rb +157 -0
  127. data/lib/insika/router/backend_pool.rb +98 -0
  128. data/lib/insika/router/hash_ring.rb +55 -0
  129. data/lib/insika/router/proxy_body.rb +34 -0
  130. data/lib/insika/router/session_key.rb +54 -0
  131. data/lib/insika/router.rb +18 -0
  132. data/lib/insika/schedule.rb +177 -0
  133. data/lib/insika/schedule_engine.rb +314 -0
  134. data/lib/insika/schedule_store.rb +208 -0
  135. data/lib/insika/server/app.rb +105 -15
  136. data/lib/insika/server/rack_app.rb +5 -1
  137. data/lib/insika/server/responses.rb +5 -5
  138. data/lib/insika/session_store.rb +34 -4
  139. data/lib/insika/settings_store.rb +8 -1
  140. data/lib/insika/skill_catalog.rb +12 -0
  141. data/lib/insika/soak/runner.rb +4 -4
  142. data/lib/insika/steer_injector.rb +21 -10
  143. data/lib/insika/studio/app.rb +591 -47
  144. data/lib/insika/studio/assets/dist/application.css +1 -1
  145. data/lib/insika/studio/assets/dist/application.js +21 -21
  146. data/lib/insika/studio/forms.rb +57 -5
  147. data/lib/insika/studio/nav_icons.rb +14 -1
  148. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  149. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  150. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  151. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  152. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  153. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  154. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  155. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  156. data/lib/insika/studio/views/_agents_master.erb +44 -0
  157. data/lib/insika/studio/views/_message.erb +49 -32
  158. data/lib/insika/studio/views/agent_detail.erb +61 -820
  159. data/lib/insika/studio/views/agents.erb +70 -57
  160. data/lib/insika/studio/views/artifact.erb +23 -0
  161. data/lib/insika/studio/views/artifacts.erb +59 -0
  162. data/lib/insika/studio/views/evals.erb +2 -2
  163. data/lib/insika/studio/views/facts.erb +1 -1
  164. data/lib/insika/studio/views/funnel.erb +1 -1
  165. data/lib/insika/studio/views/home.erb +106 -67
  166. data/lib/insika/studio/views/knowledge.erb +123 -0
  167. data/lib/insika/studio/views/layout.erb +14 -11
  168. data/lib/insika/studio/views/mcp.erb +174 -80
  169. data/lib/insika/studio/views/session.erb +231 -177
  170. data/lib/insika/studio/views/settings.erb +50 -1
  171. data/lib/insika/studio/views/skills.erb +1 -1
  172. data/lib/insika/studio/views/tools.erb +24 -9
  173. data/lib/insika/telemetry/recorder.rb +49 -1
  174. data/lib/insika/templates/browser-agent/README.md +36 -0
  175. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  176. data/lib/insika/templates/daily-digest/README.md +47 -0
  177. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  178. data/lib/insika/templates/repo-explorer/README.md +36 -0
  179. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  180. data/lib/insika/templates/research-analyst/README.md +26 -0
  181. data/lib/insika/templates/research-analyst/agent.rb +68 -0
  182. data/lib/insika/templates/review-panel/README.md +20 -0
  183. data/lib/insika/templates/review-panel/agent.rb +50 -0
  184. data/lib/insika/templates/travel-planner/README.md +35 -0
  185. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  186. data/lib/insika/templates.rb +112 -0
  187. data/lib/insika/tick.rb +24 -12
  188. data/lib/insika/timezone.rb +45 -0
  189. data/lib/insika/tool_batch.rb +67 -0
  190. data/lib/insika/tool_usage_report.rb +162 -0
  191. data/lib/insika/tools/generate_image.rb +52 -7
  192. data/lib/insika/tools/load_knowledge.rb +74 -0
  193. data/lib/insika/tools/run_persona_eval.rb +328 -0
  194. data/lib/insika/tools/save_artifact.rb +95 -0
  195. data/lib/insika/turn_budget.rb +91 -0
  196. data/lib/insika/turn_output.rb +1 -1
  197. data/lib/insika/turn_state.rb +15 -4
  198. data/lib/insika/version.rb +1 -1
  199. data/lib/insika/wiring/graph.rb +184 -12
  200. data/lib/insika/wiring/graph_chat.rb +102 -0
  201. data/lib/insika.rb +64 -0
  202. metadata +109 -5
  203. data/docs/build.md +0 -14
  204. 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
- # The periodic tick: durability stops waiting for a reboot. One
7
- # pass does two things, in this order:
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. SWEEP stale orphaned tasks (`Recovery#run(stale_after:)`) — gated by a
13
- # bucketed claim (`Recovery.claim_sweep` on "tick:<epoch/interval>"), so
14
- # exactly one worker per window sweeps. The staleness threshold is the
15
- # liveness gate: a live :running turn is bounded by turn_timeout, so
16
- # anything untouched past it cannot be alive.
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. The
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 # WS8: the daily age-based sweep; nil = none
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 output part. " \
19
- "Use when the customer asked for a picture or an image would help."
20
- param :prompt, desc: "What to draw, in detail"
21
- param :size, desc: "Optional canvas size, e.g. 1024x1024 (default from the agent config)",
22
- required: false
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).reject { |_, v| v.to_s.empty? }
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