insika 0.3.0 → 0.7.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  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 +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -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 +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -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 +73 -16
  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 +22 -2
  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/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. data/docs/understand.md +0 -10
@@ -56,7 +56,7 @@ module Insika
56
56
  config:, pending_action_store: nil, a2a: nil, provisioner: nil,
57
57
  workflow_registry: nil, onboarding: nil, profiles: nil,
58
58
  channels: nil, logger: nil, token_store: nil, outcome_store: nil,
59
- executor: nil, db_path: nil)
59
+ executor: nil, db_path: nil, tool_registry: nil, mcp_store: nil)
60
60
  @command_bus = command_bus
61
61
  @event_stream = event_stream
62
62
  @session_store = session_store
@@ -104,6 +104,17 @@ module Insika
104
104
  # Both optional — nil means the body simply omits those readings.
105
105
  @executor = executor
106
106
  @db_path = db_path
107
+ # The deployment's EFFECTIVE tool registry, so the capability view can
108
+ # answer what the agent can reach that can WRITE (the derived
109
+ # eval-profile swap list). Read-only use (names/side_effect?) — same
110
+ # constitutional footing as the profile source. nil = the view omits it.
111
+ @tool_registry = tool_registry
112
+ # The durable MCP instance CRUD (GET/PUT/DELETE
113
+ # /v1/mcp[/:name]) — a direct store read/Command surface, same footing
114
+ # as @profiles/@outcome_store. nil = the routes 404 (parity — a
115
+ # deployment that never wires an McpStore never exposes MCP config
116
+ # over HTTP, same as one that skips provisioning).
117
+ @mcp_store = mcp_store
107
118
  @heartbeat = config.fetch(:heartbeat, 15)
108
119
  @sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
109
120
  end
@@ -181,6 +192,14 @@ module Insika
181
192
  handle_list_outcomes(req)
182
193
  in ["POST", ["v1", "tools", "manifest"]]
183
194
  handle_import_tools(req)
195
+ in ["GET", ["v1", "mcp"]] if @mcp_store
196
+ handle_list_mcp
197
+ in ["GET", ["v1", "mcp", name]] if @mcp_store
198
+ handle_read_mcp(name)
199
+ in ["PUT", ["v1", "mcp"]] if @mcp_store
200
+ handle_upsert_mcp(req)
201
+ in ["DELETE", ["v1", "mcp", name]] if @mcp_store
202
+ handle_delete_mcp(req, name)
184
203
  in ["POST", ["v1", "mcp", name, "import"]]
185
204
  handle_import_mcp_tools(req, name)
186
205
  in ["POST", ["v1", "agents"]] if @provisioner
@@ -438,18 +457,61 @@ module Insika
438
457
  json_response(200, dispatch_with_timeout(command))
439
458
  end
440
459
 
441
- # POST /v1/mcp/:name/import — LIVE MCP ingestion. Same
460
+ # GET /v1/mcp — every configured MCP instance, MASKED (@mcp_store.all
461
+ # already masks — never plaintext over the wire). A direct store read,
462
+ # not a Command (constitutional rule at the top of this file); the
463
+ # generic route gate already confined this to the operator (not a
464
+ # TENANT_SURFACES entry), same footing as GET /v1/agents/:id.
465
+ def handle_list_mcp
466
+ json_response(200, { instances: @mcp_store.all })
467
+ end
468
+
469
+ # GET /v1/mcp/:name — one instance, MASKED. Missing -> 404.
470
+ def handle_read_mcp(name)
471
+ record = @mcp_store.get(name)
472
+ raise Insika::NotFoundError, "MCP instance '#{name}' not found" if record.nil?
473
+
474
+ json_response(200, record)
475
+ end
476
+
477
+ # PUT /v1/mcp — create/edit an instance (the CLI/DSL/Studio
478
+ # config surfaces share this ONE seam: the `:upsert_mcp` Command already
479
+ # registered on the bus). Per-key secret reconciliation (the `__OCULTO__`
480
+ # sentinel round-trips: preserves; a new string replaces; "" clears) —
481
+ # same contract as `Insika::McpJson`. 200 { the MASKED upserted record }.
482
+ def handle_upsert_mcp(req)
483
+ gate = gateway_gate(req)
484
+ return gate if gate
485
+
486
+ command = Insika::Command.build(:upsert_mcp, parse_body(req), transport: :http)
487
+ json_response(200, dispatch_with_timeout(command))
488
+ end
489
+
490
+ # DELETE /v1/mcp/:name — removes an instance (`:delete_mcp`). Idempotent
491
+ # (200 { existed: false } on a name that was never there — never a 404;
492
+ # deleting a thing that is already gone is not an error). The name comes
493
+ # from the ROUTE (data), not the body.
494
+ def handle_delete_mcp(req, name)
495
+ gate = gateway_gate(req)
496
+ return gate if gate
497
+
498
+ command = Insika::Command.build(:delete_mcp, { name: name }, transport: :http)
499
+ json_response(200, dispatch_with_timeout(command))
500
+ end
501
+
502
+ # POST /v1/mcp/:name/import — kept as the alias for the
503
+ # "Refresh tools" action (the route predates the live registry; the name
504
+ # stuck). Dispatches :refresh_mcp_tools — LIVE connect + tools/list +
505
+ # write McpStore#tools_cache, never the retired ingestor snapshot. Same
442
506
  # Bearer as provisioning (gateway_token, fail-closed): it's an authoring
443
- # surface. Discovers the tools of the MCP instance `:name` (via a client injectable
444
- # at the composition root) and ingests them as data-tools (reuses :import_tools:
445
- # upsert + hot reload). Dispatches :import_mcp_tools -> 200 { per-tool report
446
- # + instance }. Missing instance -> 404; disabled/no-url -> 422; per-tool
447
- # failure isolated in `errors[]` (R4). The name comes from the ROUTE (data), not the body.
507
+ # surface. Missing instance -> 404; disabled -> 422; a transport failure
508
+ # surfaces as whatever #call's rescue maps it to. The name comes from the
509
+ # ROUTE (data), not the body.
448
510
  def handle_import_mcp_tools(req, name)
449
511
  gate = gateway_gate(req)
450
512
  return gate if gate
451
513
 
452
- command = Insika::Command.build(:import_mcp_tools, { name: name }, transport: :http)
514
+ command = Insika::Command.build(:refresh_mcp_tools, { name: name }, transport: :http)
453
515
  json_response(200, dispatch_with_timeout(command))
454
516
  end
455
517
 
@@ -466,22 +528,50 @@ module Insika
466
528
  # (a client: it never reads a store) can tell "this case cannot run here" from
467
529
  # "this case failed". Deliberately NOT the profile: the prompt,
468
530
  # the model and the guardrail config are none of the caller's business. Just the
469
- # two facts a case declares `requires` against.
531
+ # three facts a case declares `requires` against — plus the side-effect set,
532
+ # which is what an eval profile must swap.
470
533
  #
471
534
  # `tools` is the DECLARED allowlist, and `null` means the agent has an open one
472
535
  # (every registered tool) — the client reads that as "cannot rule anything out"
473
536
  # and runs the case rather than skipping it.
537
+ #
538
+ # `side_effect_tools` is DERIVED here from the deployment's own registry (the
539
+ # effective one the Executor uses): the agent's reachable tools the registry
540
+ # marks `side_effect` (a data-tool's non-GET method, the MCP ingestor's
541
+ # `tools/call`). It is the list a simulated run's eval profile must cover —
542
+ # and it is a fact of the deployment, never a hand-maintained client list.
543
+ # Absent (no registry wired) -> the key is omitted, and the view behaves as
544
+ # before.
474
545
  def handle_read_agent(id)
475
546
  profile = @profiles.fetch(id)
476
547
  raise Insika::NotFoundError, "agent not found: #{id}" if profile.nil?
477
548
 
478
549
  allow = profile.tools_allow
479
550
  deny = Array(profile.tools_deny).map(&:to_s)
480
- json_response(200, {
481
- id: profile.id,
482
- tools: allow.nil? ? nil : (Array(allow).map(&:to_s) - deny),
483
- capabilities: Array(profile.capabilities_declared).map(&:to_s)
484
- })
551
+ payload = {
552
+ id: profile.id,
553
+ # `tools: null` is MEANINGFUL (an open allowlist — the client reads it
554
+ # as "cannot rule anything out"), so it must survive, unlike the new
555
+ # field below.
556
+ tools: allow.nil? ? nil : (Array(allow).map(&:to_s) - deny),
557
+ capabilities: Array(profile.capabilities_declared).map(&:to_s)
558
+ }
559
+ derived = side_effect_tools(profile)
560
+ payload[:side_effect_tools] = derived unless derived.nil?
561
+ json_response(200, payload)
562
+ end
563
+
564
+ # The derived side-effect set for an agent: its reachable tools (the same
565
+ # allowlist − deny resolution the `tools` field uses) that the registry marks
566
+ # `side_effect`, sorted for a stable answer. -> [names] | nil (no registry).
567
+ def side_effect_tools(profile)
568
+ return nil unless @tool_registry
569
+
570
+ allowed = if profile.tools_allow.nil? then Array(@tool_registry.names)
571
+ else Array(profile.tools_allow).map(&:to_s)
572
+ end
573
+ denied = Array(profile.tools_deny).map(&:to_s)
574
+ (allowed - denied).select { |name| @tool_registry.side_effect?(name) }.sort
485
575
  end
486
576
 
487
577
  # `/v1` only — `/a2a` is versioned by its own JSON-RPC spec and a channel's
@@ -887,7 +977,7 @@ TENANT_SURFACES = [
887
977
  parts = payload[:parts] || payload["parts"]
888
978
  if parts && !Insika::Media.well_formed?(parts)
889
979
  raise Insika::ValidationError,
890
- "malformed content part — each part must be {type: text|image|audio} with text/url"
980
+ "malformed content part — each part must be {type: text|image|audio|document} with text/url"
891
981
  end
892
982
  # WS9: the only declared message source is "voice" (pre-transcribed
893
983
  # audio) — a consumer that writes prose here is told so, not silently.
@@ -59,8 +59,10 @@ module Insika
59
59
  pending_action_store: @graph.pending_action_store,
60
60
  provisioner: Insika::PackImporter.new(bus: @graph.bus, profiles: @graph.profiles),
61
61
  # GET /v1/agents/:id — the read-only capability view a case's `requires`
62
- # resolves against.
62
+ # resolves against (and the derived side-effect set an eval profile must
63
+ # cover).
63
64
  profiles: @graph.profiles,
65
+ tool_registry: @graph.tool_registry,
64
66
  # the OSS onboarding surface (start.md + models.json + docs).
65
67
  # This is the primary "build my first agent" target — models.json reports the
66
68
  # DSL's stores + the agents this process serves (each id IS the `model`).
@@ -76,6 +78,8 @@ module Insika
76
78
  config: { gateway_token: @token, tenancy: tenancy }.merge(@config),
77
79
  token_store: store,
78
80
  outcome_store: @graph.outcome_store,
81
+ # GET/PUT/DELETE /v1/mcp[/:name] — the config surface.
82
+ mcp_store: @rt.component(:mcp_store),
79
83
  # a 500's error_ref must be findable in the process log.
80
84
  logger: $stdout
81
85
  )
@@ -53,7 +53,7 @@ module Insika
53
53
  if raw.is_a?(Array)
54
54
  unless Insika::Media.well_formed?(raw)
55
55
  raise Insika::ValidationError,
56
- "malformed content part — each part must be {type: text|image|audio} with text/url"
56
+ "malformed content part — each part must be {type: text|image|audio|document} with text/url"
57
57
  end
58
58
 
59
59
  normalized = Insika::Media.parts(raw).map do |p|
@@ -68,6 +68,18 @@ module Insika
68
68
  self
69
69
  end
70
70
 
71
+ # Appends roots at the END — lowest precedence, so a workspace skill always
72
+ # overrides a same-named one shipped by a plugin. This is how
73
+ # the plugin Loader's `skill_dirs` reach the catalog at boot; reloads only
74
+ # when something new actually arrived.
75
+ def add_roots(dirs)
76
+ added = Array(dirs).map { |d| File.expand_path(d.to_s) } - @roots.map { |r| File.expand_path(r) }
77
+ return self if added.empty?
78
+
79
+ @roots.concat(added)
80
+ reload
81
+ end
82
+
71
83
  # Per-agent allowlist: nil -> all | [] -> none | [names] -> subset. `agent`
72
84
  # selects WHICH body each allowed name resolves to (see #find); the allowlist is
73
85
  # by NAME either way, so specializing a skill never touches the allowlist.
@@ -69,14 +69,32 @@ module Insika
69
69
  inject! if @seen >= @expected
70
70
  end
71
71
 
72
+ # Tail-appends whatever is in the mailbox right now and reports how many. Called
73
+ # at a batch boundary (#inject!) and ONCE MORE by the Executor when the run ended
74
+ # with a message no boundary ever arrived for (a text-only turn closes no batch) —
75
+ # the append, the running count and the `:turn_steered` event belong in one place
76
+ # either way.
77
+ def absorb_pending!
78
+ texts = @actor.take_user_messages!
79
+ return 0 if texts.empty?
80
+
81
+ texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
82
+ @injected += texts.size
83
+ # Counts only, never content — the text is already in the transcript, which is the
84
+ # surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
85
+ @emit.call(:turn_steered, { count: texts.size, total: @injected })
86
+ texts.size
87
+ end
88
+
72
89
  private
73
90
 
74
91
  def open_batch(message)
75
92
  calls = field(message, :tool_calls)
76
93
  size = calls.respond_to?(:size) ? calls.size : 0
77
94
  # A message with no tool call is the model talking, not a batch: leave any
78
- # pending message where it is (the turn is about to end, and the Executor
79
- # releases it as a follow-up turn rather than answering it half-way).
95
+ # pending message where it is. The turn is about to end, and the Executor
96
+ # absorbs it there in ONE extra round (#absorb_pending!) — a text-only turn
97
+ # closes no batch, and answering it half-way is not an option.
80
98
  return @expected = nil if size.zero?
81
99
 
82
100
  @expected = size
@@ -88,14 +106,7 @@ module Insika
88
106
  @expected = nil
89
107
  return if @halted # nothing will read it: leave it in the mailbox
90
108
 
91
- texts = @actor.take_user_messages!
92
- return if texts.empty?
93
-
94
- texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
95
- @injected += texts.size
96
- # Counts only, never content — the text is already in the transcript, which is the
97
- # surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
98
- @emit.call(:turn_steered, { count: texts.size, total: @injected })
109
+ absorb_pending!
99
110
  end
100
111
 
101
112
  def halt?(result) = defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)