insika 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require_relative "workflow_adapter"
5
+
6
+ module Insika
7
+ module DSL
8
+ # The live graph behind a Definition: it assembles the SAME authoring graph a
9
+ # concrete deployment does (Insika::Wiring::Graph + the runtime-authoring
10
+ # command surface), configures RubyLLM, and imports the definition's pack via
11
+ # the standard PackImporter. From there the agent is indistinguishable from one
12
+ # provisioned by any other pack — chat and serve just drive the normal path.
13
+ #
14
+ # Built lazily by Definition#runtime, so `require "insika"` never pays for
15
+ # ruby_llm or the HTTP server.
16
+ class Runtime
17
+ TERMINAL = %i[task_completed task_failed task_cancelled].freeze
18
+
19
+ # graph: the assembled Wiring::Graph::Result; pack: the PRIMARY imported
20
+ # Pack; packs: every imported Pack (one per agent — a System has N, a
21
+ # Definition has one). All read by the server boot and the specs.
22
+ attr_reader :graph, :pack, :packs
23
+
24
+ # `definition` is a Definition (one agent) or a System (N agents). Both
25
+ # answer #pack/#id; a System also answers #packs. Duck-typed on purpose:
26
+ # the runtime does not care how many agents it hosts, only that each one
27
+ # arrives as an ordinary Pack.
28
+ #
29
+ # `backend`: the store this graph owns. nil = the historic
30
+ # path, `INSIKA_DB` or memory — ENV is a default, never a requirement (the
31
+ # embed contract). An injected backend wins and is never widened
32
+ # back to the environment.
33
+ def initialize(definition, backend: nil)
34
+ @definition = definition
35
+ @injected_backend = backend
36
+ @packs = (definition.respond_to?(:packs) ? Array(definition.packs) : [definition.pack]).freeze
37
+ @pack = @packs.first
38
+ # the graph's OWN RubyLLM config, built before the graph so
39
+ # every collaborator that talks to a provider is handed it at wiring time
40
+ # rather than reading a process-wide singleton at call time.
41
+ @llm = build_llm_context
42
+ @graph = build_graph
43
+ seed_default_model
44
+ import_packs
45
+ end
46
+
47
+ # The graph's RubyLLM context — an isolated config dup that
48
+ # answers #chat. nil only under the test stub, where the fallback is the
49
+ # RubyLLM constant itself. Read by the specs and by anything a host wires
50
+ # alongside this graph.
51
+ attr_reader :llm
52
+
53
+ # An imported profile, read back from the store (config-over-code
54
+ # round-trip). Defaults to the primary agent.
55
+ def profile(agent_id = nil)
56
+ id = (agent_id || @definition.id).to_s
57
+ @graph.profiles.fetch(id) ||
58
+ (raise Insika::Error, "agent '#{id}' was not imported")
59
+ end
60
+
61
+ # One turn, in-process → the assistant's text. session_id nil = stateless
62
+ # one-shot; a value threads multi-turn memory (created on first use).
63
+ # `agent:` targets one agent of a system (default: the primary).
64
+ def chat(message, session_id: nil, timeout: nil, agent: nil)
65
+ command = Insika::Command.build(
66
+ :send_message,
67
+ { agent: (agent || @definition.id).to_s, message: message, session_id: session_id },
68
+ transport: :cli
69
+ )
70
+ run_command(command, session_id: session_id, timeout: timeout)[:text]
71
+ end
72
+
73
+ # Triggers a declared workflow and returns its OUTPUT (the typed value the
74
+ # workflow returned), not the turn text. A schema violation on the input
75
+ # raises here, synchronously, with no run created — same contract as
76
+ # POST /v1/workflows/:name.
77
+ def run_workflow(name, input:, agent:, timeout: nil)
78
+ command = Insika::Command.build(
79
+ :trigger_workflow, { workflow: name, agent: agent, input: input }, transport: :cli
80
+ )
81
+ outcome = run_command(command, session_id: nil, timeout: timeout)
82
+ outcome.key?(:output) ? outcome[:output] : outcome[:text]
83
+ end
84
+
85
+ # Boot the control UI (/studio) + drop-in API (/v1). Blocks on the reactor.
86
+ def serve(port:, host:, token:, **_opts)
87
+ require_relative "server_boot"
88
+ ServerBoot.new(self, port: port, host: host, token: token).run
89
+ end
90
+
91
+ # A named collaborator (settings_store, tool_store, …) the server boot reads.
92
+ def component(name) = @components.fetch(name)
93
+
94
+ private
95
+
96
+ # Dispatch + drain, shared by a turn and a workflow run: subscribe BEFORE
97
+ # dispatching (the fiber may emit eagerly), bind to the task, read to the
98
+ # terminal event. A synchronous handler error (unknown agent/workflow, bad
99
+ # input against the schema) propagates from `dispatch` untouched — the
100
+ # caller sees the real ValidationError/NotFoundError, not a turn failure.
101
+ def run_command(command, session_id:, timeout:)
102
+ require "async"
103
+ outcome = {}
104
+ rejected = nil
105
+ Async do |task|
106
+ serving = !session_id.nil?
107
+ @graph.executor.supervised = true if serving
108
+ ensure_session(session_id) if session_id
109
+ sub = @graph.event_stream.subscribe
110
+ res = begin
111
+ @graph.bus.dispatch(command)
112
+ rescue StandardError => e
113
+ # CAPTURED, not re-raised inside the reactor: letting it escape the
114
+ # Async block logs "Task may have ended with unhandled exception" —
115
+ # alarming noise for a DOCUMENTED rejection (a schema violation, an
116
+ # unknown agent). Re-raised verbatim below, outside the reactor.
117
+ sub.close
118
+ rejected = e
119
+ next
120
+ end
121
+ sub.bind(task_id: res[:task_id])
122
+ drain(sub, outcome, task, timeout)
123
+ teardown_serving if serving
124
+ end.wait
125
+ raise rejected if rejected
126
+ raise Insika::Error, "turn #{outcome[:error]}" if outcome[:error]
127
+
128
+ outcome
129
+ end
130
+
131
+ # Reads the stream until this turn reaches a terminal event, then stops.
132
+ # A workflow run also carries its typed OUTPUT, which is not the turn text.
133
+ def drain(sub, outcome, task, timeout)
134
+ reader = task.async do
135
+ sub.each do |ev|
136
+ case ev.type
137
+ when :task_completed then outcome[:text] = ev.data[:content].to_s
138
+ when :workflow_completed then outcome[:output] = ev.data[:output]
139
+ when :task_failed then outcome[:error] = "failed: #{ev.data[:message] || ev.data[:error]}"
140
+ when :task_cancelled then outcome[:error] = "cancelled"
141
+ end
142
+ sub.close if TERMINAL.include?(ev.type)
143
+ end
144
+ end
145
+ timeout ? task.with_timeout(timeout) { reader.wait } : reader.wait
146
+ rescue Async::TimeoutError
147
+ outcome[:error] = "timed out after #{timeout}s"
148
+ sub.close
149
+ end
150
+
151
+ def ensure_session(session_id)
152
+ @graph.session_store.find(session_id) || @graph.session_store.create(id: session_id, vars: {})
153
+ end
154
+
155
+ def teardown_serving
156
+ @graph.executor.stop_session_actors
157
+ @graph.executor.instance_variable_get(:@supervisor)&.stop
158
+ end
159
+
160
+ # --- graph assembly (mirrors config/deployment.rb, generically) ------
161
+
162
+ def build_graph
163
+ backend = @injected_backend || Insika::Wiring::Graph.backend_from_env
164
+ # :workflow_allowlist joins the builtins here for the same reason the
165
+ # minimal wiring registers it: this root CAN expose workflows. An agent
166
+ # that never names one keeps `workflows_allow` nil = all (parity).
167
+ spine = Insika::Wiring::Graph.spine(
168
+ backend: backend,
169
+ extra_policy_builtins: { workflow_allowlist: Insika::Policy::Builtin::WorkflowAllowlist }
170
+ )
171
+ c = build_components(backend, spine)
172
+ @components = c
173
+
174
+ graph = Insika::Wiring::Graph.build(
175
+ spine: spine, profiles: c[:profile_source],
176
+ tool_registry: c[:tool_registry], tool_catalog: c[:tool_catalog],
177
+ skill_catalog: c[:skill_catalog], prompt_catalog: c[:prompt_catalog],
178
+ guardrails: c[:guardrails], context_providers: context_providers(spine, c),
179
+ edge_limiter: c[:edge_limiter],
180
+ executor_extra: { settings_store: c[:settings_store], tool_trace_store: c[:tool_trace_store],
181
+ context_trace_store: c[:context_trace_store], llm: @llm }
182
+ )
183
+ register_authoring_commands(graph, c)
184
+ register_workflows(graph)
185
+ graph
186
+ end
187
+
188
+ # Declared workflows land in the SAME registry a deployment uses, so a DSL
189
+ # workflow is discoverable (`GET /v1/workflows`), schema-validated and
190
+ # durable (its run IS a Task) exactly like a hand-wired one. The
191
+ # :trigger_workflow command is registered only when there is something to
192
+ # trigger — parity: a system without workflows behaves as before.
193
+ def register_workflows(graph)
194
+ declared = @definition.respond_to?(:workflows) ? Array(@definition.workflows) : []
195
+ return if declared.empty?
196
+
197
+ declared.each do |w|
198
+ callable = WorkflowAdapter.new(block: w[:block], runtime: self)
199
+ graph.workflow_registry.register(
200
+ w[:name], callable,
201
+ description: w[:description], input_schema: w[:input_schema], output_schema: w[:output_schema]
202
+ )
203
+ end
204
+
205
+ graph.bus.register(:trigger_workflow, Insika::Commands::TriggerWorkflow.new(
206
+ profiles: graph.profiles, session_store: graph.session_store,
207
+ task_store: graph.task_store, executor: graph.executor,
208
+ workflow_registry: graph.workflow_registry
209
+ ))
210
+ end
211
+
212
+ def build_components(backend, spine)
213
+ config_store = Insika::ConfigStore.new(store: backend)
214
+ settings_store = Insika::SettingsStore.new(config_store: config_store)
215
+ provider_store = Insika::LLMProviderStore.new(config_store: config_store)
216
+ tool_store = Insika::ToolStore.new(config_store: config_store)
217
+ skill_store = Insika::SkillStore.new(config_store: config_store)
218
+ tool_registry = Insika::OverlayToolRegistry.new(
219
+ base: spine.code_tool_registry, tool_store: tool_store,
220
+ http: Insika::HttpClient.new, event_stream: spine.event_stream
221
+ )
222
+ {
223
+ backend: backend, config_store: config_store,
224
+ settings_store: settings_store, provider_store: provider_store,
225
+ configurator: Insika::LLMConfigurator.new(provider_store: provider_store, configure: llm_configure),
226
+ agent_file_store: Insika::AgentFileStore.new(config_store: config_store),
227
+ skill_store: skill_store, tool_store: tool_store,
228
+ system_file_store: Insika::SystemFileStore.new(config_store: config_store),
229
+ mcp_store: Insika::McpStore.new(config_store: config_store),
230
+ tool_trace_store: Insika::ToolTraceStore.new(store: backend),
231
+ context_trace_store: Insika::ContextTraceStore.new(store: backend),
232
+ tool_registry: tool_registry,
233
+ tool_catalog: Insika::ToolCatalog.new(tool_registry: tool_registry),
234
+ skill_catalog: Insika::SkillCatalog.new([], store: skill_store),
235
+ prompt_catalog: Insika::PromptCatalog.new([]),
236
+ profile_source: Insika::StoredProfileSource.new(config_store: config_store),
237
+ # the moderator/validator tiers ask a model too. A graph reading its
238
+ # own key for the turn but the global one for a guardrail would be a
239
+ # credential leak wearing the look of isolation.
240
+ guardrails: Insika::Safety::Factory.new(settings_store: settings_store, llm: @llm),
241
+ edge_limiter: Insika::EdgeLimiter.new(
242
+ ledger: Insika::UsageLedger.new(store: backend), settings_store: settings_store,
243
+ budget_ledger: spine.budget_ledger, event_stream: spine.event_stream
244
+ )
245
+ }
246
+ end
247
+
248
+ def context_providers(spine, c)
249
+ [
250
+ Insika::Context::Providers::Request.new,
251
+ Insika::Context::Providers::Prompt.new(
252
+ base: "", files: [], catalog: c[:prompt_catalog],
253
+ agent_files: c[:agent_file_store], system_files: c[:system_file_store]
254
+ ),
255
+ Insika::Context::Providers::Skill.new(catalog: c[:skill_catalog]),
256
+ Insika::Context::Providers::SkillTrigger.new(catalog: c[:skill_catalog]),
257
+ Insika::Context::Providers::ToolSearch.new(catalog: c[:tool_catalog]),
258
+ Insika::Context::Providers::Memory.new(store: spine.memory_store),
259
+ Insika::Context::Providers::Session.new(session_store: spine.session_store)
260
+ ].freeze
261
+ end
262
+
263
+ # The runtime-authoring surface a pack import needs (create_agent +
264
+ # write_agent_file/skill/data_tool), plus the rest of the CRUD so /studio is
265
+ # fully functional under #serve. Same handlers as config/deployment.rb.
266
+ def register_authoring_commands(graph, c)
267
+ bus = graph.bus
268
+ es = graph.event_stream
269
+ mem = graph.memory_store
270
+ bus.register(:create_agent, Insika::Commands::CreateAgent.new(profile_source: c[:profile_source], event_stream: es))
271
+ bus.register(:update_agent, Insika::Commands::UpdateAgent.new(profile_source: c[:profile_source], event_stream: es))
272
+ bus.register(:delete_agent, Insika::Commands::DeleteAgent.new(profile_source: c[:profile_source], event_stream: es))
273
+ bus.register(:set_agent_tools, Insika::Commands::SetAgentTools.new(profile_source: c[:profile_source], event_stream: es))
274
+ bus.register(:write_agent_file, Insika::Commands::WriteAgentFile.new(profile_source: c[:profile_source], agent_file_store: c[:agent_file_store], event_stream: es))
275
+ bus.register(:delete_agent_file, Insika::Commands::DeleteAgentFile.new(profile_source: c[:profile_source], agent_file_store: c[:agent_file_store], event_stream: es))
276
+ bus.register(:restore_agent_file, Insika::Commands::RestoreAgentFile.new(profile_source: c[:profile_source], agent_file_store: c[:agent_file_store], event_stream: es))
277
+ bus.register(:write_skill, Insika::Commands::WriteSkill.new(skill_store: c[:skill_store], skill_catalog: c[:skill_catalog], event_stream: es))
278
+ bus.register(:set_skill_agents, Insika::Commands::SetSkillAgents.new(profile_source: c[:profile_source], event_stream: es))
279
+ bus.register(:memory_put_fact, Insika::Commands::MemoryPutFact.new(memory_store: mem, event_stream: es))
280
+ bus.register(:memory_forget_fact, Insika::Commands::MemoryForgetFact.new(memory_store: mem, event_stream: es))
281
+ bus.register(:memory_add_note, Insika::Commands::MemoryAddNote.new(memory_store: mem, event_stream: es))
282
+ bus.register(:update_settings, Insika::Commands::UpdateSettings.new(settings_store: c[:settings_store], event_stream: es))
283
+ bus.register(:upsert_llm_provider, Insika::Commands::UpsertLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
284
+ bus.register(:delete_llm_provider, Insika::Commands::DeleteLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
285
+ bus.register(:upsert_mcp, Insika::Commands::UpsertMcp.new(mcp_store: c[:mcp_store], event_stream: es))
286
+ bus.register(:delete_mcp, Insika::Commands::DeleteMcp.new(mcp_store: c[:mcp_store], event_stream: es))
287
+ bus.register(:write_system_file, Insika::Commands::WriteSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
288
+ bus.register(:delete_system_file, Insika::Commands::DeleteSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
289
+ bus.register(:restore_system_file, Insika::Commands::RestoreSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
290
+ bus.register(:write_data_tool, Insika::Commands::WriteDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
291
+ bus.register(:delete_data_tool, Insika::Commands::DeleteDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
292
+ bus.register(:restore_data_tool, Insika::Commands::RestoreDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
293
+ end
294
+
295
+ # --- RubyLLM configuration ------------------------------------------
296
+
297
+ def provider_name
298
+ (@definition.runtime_options[:provider] || @pack.config[:provider] || "deepseek").to_s
299
+ end
300
+
301
+ # Every distinct provider the system uses, primary first. A fan-out system
302
+ # routinely mixes providers (one specialist on Anthropic, another on
303
+ # OpenAI), and configuring only the primary would leave the others without
304
+ # a key — a 401 at the first delegation, far from its cause.
305
+ def provider_names
306
+ ([provider_name] + @packs.filter_map { |p| Insika::Coercion.presence(p.config[:provider])&.to_s }).uniq
307
+ end
308
+
309
+ # the graph's credentials belong to the graph. `RubyLLM.context`
310
+ # is an isolated dup of the config that answers #chat, so two graphs in one
311
+ # process no longer overwrite each other's keys: the global
312
+ # `RubyLLM.configure` is a single slot PER PROVIDER, and the loser of that
313
+ # race got no error, just the other tenant's key.
314
+ #
315
+ # Returns nil under the test stub (no #context) — every consumer falls back
316
+ # to the RubyLLM constant, which is exactly the old behavior.
317
+ def build_llm_context
318
+ return nil unless RubyLLM.respond_to?(:context)
319
+
320
+ primary = provider_name
321
+ RubyLLM.context do |cfg|
322
+ provider_names.each do |provider|
323
+ # The explicit api_key/api_base belong to the PRIMARY provider; the
324
+ # others resolve from the conventional <PROVIDER>_API_KEY.
325
+ explicit = provider == primary
326
+ key = (explicit ? @definition.runtime_options[:api_key] : nil) || ENV["#{provider.upcase}_API_KEY"]
327
+ base = (explicit ? @definition.runtime_options[:api_base] : nil) || default_base_for(provider)
328
+ apply_llm(cfg, "#{provider}_api_key", key)
329
+ apply_llm(cfg, "#{provider}_api_base", base)
330
+ end
331
+ cfg.request_timeout = 120
332
+ cfg.max_retries = 2
333
+ end
334
+ end
335
+
336
+ # The LLMConfigurator's target (Studio: edit a provider key, no restart).
337
+ # Scoped to THIS graph's context — a `RubyLLM.configure` here would reach
338
+ # into every other graph in the process, which is the leak exists to
339
+ # close. nil = no context (test stub) -> the configurator's own global
340
+ # default, unchanged.
341
+ def llm_configure
342
+ return nil if @llm.nil?
343
+
344
+ context = @llm
345
+ ->(&blk) { blk.call(context.config) }
346
+ end
347
+
348
+ def apply_llm(cfg, setter, value)
349
+ return if value.nil? || value.to_s.empty?
350
+
351
+ writer = "#{setter}="
352
+ cfg.public_send(writer, value) if cfg.respond_to?(writer)
353
+ end
354
+
355
+ # Known convenience default (DeepSeek's OpenAI-compatible base); other
356
+ # providers use the gem's default unless api_base is given.
357
+ def default_base_for(provider)
358
+ provider == "deepseek" ? "https://api.deepseek.com/v1" : nil
359
+ end
360
+
361
+ # Seed the platform default model/provider so a modelless agent works too.
362
+ def seed_default_model
363
+ c = @components
364
+ return unless Insika::Coercion.presence(c[:settings_store].get["default_model"]).nil?
365
+
366
+ model = @pack.config[:model]
367
+ return if model.nil?
368
+
369
+ c[:settings_store].update("default_model" => model.to_s, "default_provider" => provider_name)
370
+ end
371
+
372
+ # One import per agent, in declaration order. Order is not significant for
373
+ # delegation: `SubagentGraph.validate!` treats an unknown child id as a
374
+ # LEAF, so a parent declared before its children imports cleanly and the
375
+ # reference resolves once they land.
376
+ def import_packs
377
+ importer = Insika::PackImporter.new(bus: @graph.bus, profiles: @components[:profile_source])
378
+ @packs.each { |pack| importer.import(pack) }
379
+ end
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Boots the single-process HTTP server for Definition#serve: /studio (control UI,
4
+ # cookie-auth) + /v1 (drop-in OpenAI-Responses API), the same topology as
5
+ # scripts/serve_real.rb, but wired from a DSL Runtime instead of a deployment.
6
+ # Required lazily (pulls the server + Studio + Async) — never on `require "insika"`.
7
+
8
+ require "async"
9
+ require "async/http/server"
10
+ require "async/http/endpoint"
11
+ require "protocol/rack"
12
+ require "rack/urlmap"
13
+
14
+ require_relative "../server/rack_app"
15
+ require_relative "../studio/app"
16
+
17
+ module Insika
18
+ module DSL
19
+ class ServerBoot
20
+ def initialize(runtime, port:, host:, token:)
21
+ @rt = runtime
22
+ @graph = runtime.graph
23
+ @port = port
24
+ @host = host
25
+ # Fixed local token: logs into the Studio (cookie) and gates /v1 (Bearer).
26
+ # Never a real secret — override with `token:` or ADMIN_TOKEN.
27
+ @token = token || ENV.fetch("ADMIN_TOKEN", "local-demo")
28
+ # the /v1 app is a value now — the same one a host mounts.
29
+ # This boot adds the Studio and a reactor around it, nothing else.
30
+ @builder = Insika::Server::AppBuilder.new(runtime, token: @token)
31
+ end
32
+
33
+ def run
34
+ configure_studio
35
+ dispatch = Rack::URLMap.new("/studio" => Studio::App, "/" => @builder.app)
36
+ endpoint = Async::HTTP::Endpoint.parse("http://#{@host}:#{@port}")
37
+ middleware = Protocol::Rack::Adapter.new(dispatch)
38
+ # the OTEL bridge is opt-in but must be reachable from the DSL
39
+ # front door too — the convention is worthless if only config.ru can export.
40
+ # nil (the default) -> attach is a no-op and no gem is loaded.
41
+ telemetry = Insika::Telemetry.setup(service_name: ENV.fetch("OTEL_SERVICE_NAME", "insika"))
42
+
43
+ banner(telemetry)
44
+ # first Ctrl-C/SIGTERM closes the intake and drains in-flight
45
+ # turns (INSIKA_DRAIN_TIMEOUT, default 20s); a second signal skips the wait.
46
+ Insika::Shutdown.install(executor: @graph.executor)
47
+ Async do
48
+ @graph.executor.supervised = true # serving mode: turns survive disconnects
49
+ Insika::Telemetry.attach(event_stream: @graph.event_stream, recorder: telemetry)
50
+ Async::HTTP::Server.new(middleware, endpoint).run
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def workflows? = @builder.workflows?
57
+ def channels? = @builder.channels?
58
+
59
+ def configure_studio
60
+ Studio::App.configure(
61
+ command_bus: @graph.bus, profile_source: @graph.profiles,
62
+ event_stream: @graph.event_stream,
63
+ config: { admin_token: @token, persistence: persistence },
64
+ agent_file_store: @rt.component(:agent_file_store), skill_store: @rt.component(:skill_store),
65
+ skill_catalog: @graph.skill_catalog, tool_catalog: @graph.tool_catalog,
66
+ tool_store: @rt.component(:tool_store), memory_store: @graph.memory_store,
67
+ session_store: @graph.session_store,
68
+ settings_store: @rt.component(:settings_store), llm_provider_store: @rt.component(:provider_store),
69
+ mcp_store: @rt.component(:mcp_store), system_file_store: @rt.component(:system_file_store),
70
+ tool_trace_store: @rt.component(:tool_trace_store),
71
+ context_trace_store: @rt.component(:context_trace_store),
72
+ task_store: @graph.task_store, checkpoint_store: @graph.checkpoint_store,
73
+ pending_action_store: @graph.pending_action_store
74
+ )
75
+ end
76
+
77
+ def persistence
78
+ @graph.durable? ? "durable (sqlite)" : "ephemeral (memory)"
79
+ end
80
+
81
+ def banner(telemetry = nil)
82
+ base = "http://#{@host}:#{@port}"
83
+ ids = @rt.packs.map { |p| p.config[:id].to_s }
84
+ headline = ids.one? ? "agent \"#{ids.first}\"" : "#{ids.size} agents: #{ids.map(&:inspect).join(', ')}"
85
+ models = ids.one? ? "model: \"#{ids.first}\"" : "model: one of #{ids.map(&:inspect).join(', ')}"
86
+ puts "\e[1mInsika — serving #{headline}\e[0m"
87
+ puts " #{base}/studio → control UI (login token: \"#{@token}\")"
88
+ puts " #{base}/v1/responses → drop-in API (Bearer \"#{@token}\", #{models})"
89
+ puts " #{base}/start.md → onboarding for your coding agent (+ /models.json, /docs)"
90
+ puts " #{base}/v1/workflows → #{@graph.workflow_registry.names.join(', ')}" if workflows?
91
+ puts " #{base}/channels/… → #{@graph.channel_registry.names.join(', ')}" if channels?
92
+ # Only when on: an off-by-default line in the OSS front door is noise.
93
+ puts " OTEL → #{Insika::Telemetry.metrics? ? "traces + metrics" : "traces"} to OTLP" if telemetry
94
+ puts " Ctrl-C to stop (drains in-flight turns; press twice to skip the wait)."
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module DSL
5
+ # The result of `Insika.system { … }`: N agents sharing ONE runtime graph.
6
+ #
7
+ # It is the multi-agent counterpart of Definition, and deliberately the same
8
+ # shape — `reply` for a turn, `serve` for the server, `to_packs` for the data.
9
+ # Nothing here is a new engine path: every agent is still an ordinary Pack
10
+ # imported by the standard PackImporter, so a system of three agents is
11
+ # indistinguishable from three hand-written packs provisioned into the same
12
+ # deployment.
13
+ #
14
+ # Why it exists: delegation (`subagents`), fan-out/fan-in and routing all
15
+ # require the child agents to be resolvable in the SAME ProfileSource. A
16
+ # Definition owns exactly one pack, so those patterns had no home in the DSL.
17
+ class System
18
+ attr_reader :definitions, :workflows, :runtime_options
19
+
20
+ # `backend`: the store this system's graph owns. nil = the
21
+ # historic path (INSIKA_DB, or memory when unset). Set by `Insika.embed`.
22
+ def initialize(definitions:, workflows: [], runtime: {}, backend: nil)
23
+ @definitions = definitions.freeze
24
+ @workflows = workflows.freeze
25
+ @backend = backend
26
+ # Agent-level runtime knobs merged in declaration order, then the
27
+ # system-level ones on top (an explicit `provider`/`api_key` in the
28
+ # system block is the shared default and wins the tie).
29
+ @runtime_options = definitions.map(&:runtime_options)
30
+ .reduce({}) { |acc, opts| acc.merge(opts) }
31
+ .merge(runtime)
32
+ .freeze
33
+ end
34
+
35
+ def ids = definitions.map(&:id)
36
+
37
+ # The primary agent: the first declared. Only used where a single id is
38
+ # structurally required (the default model seed, the banner) — never to
39
+ # guess the target of a turn, which is always explicit.
40
+ def id = definitions.first.id
41
+
42
+ # The portable artifacts — one Pack per agent. Hand them to any
43
+ # PackImporter and you get this same system.
44
+ def to_packs = definitions.map(&:to_pack)
45
+ def packs = to_packs
46
+
47
+ # Runtime reads it for the provider/default-model seed.
48
+ def pack = definitions.first.to_pack
49
+
50
+ def find(agent_id)
51
+ definitions.find { |d| d.id == agent_id.to_s } ||
52
+ (raise Insika::NotFoundError, "agent '#{agent_id}' is not in this system (have: #{ids.join(', ')})")
53
+ end
54
+
55
+ # The AgentProfile the engine runs for one agent, read back from the store.
56
+ def profile(agent_id) = runtime.profile(find(agent_id).id)
57
+
58
+ # One turn against ONE agent of the system, in-process. The agent is always
59
+ # explicit: with several agents in the graph, inferring the target would be
60
+ # a guess, and a wrong guess is a silently wrong conversation.
61
+ def reply(agent_id, message, session: nil, timeout: nil)
62
+ runtime.chat(message, agent: find(agent_id).id, session_id: session, timeout: timeout)
63
+ end
64
+ alias_method :chat, :reply
65
+
66
+ # Runs a declared workflow and returns its output. `agent:` is the profile
67
+ # the run executes under (policy, limits, context) — it defaults to the
68
+ # primary agent, since a workflow's own steps pick their agents explicitly.
69
+ # A bad input raises before any run is created (schema is enforced at the
70
+ # edge, exactly as it is over HTTP).
71
+ def run(workflow_name, input: {}, agent: nil, timeout: nil)
72
+ runtime.run_workflow(workflow_name.to_s, input: input,
73
+ agent: (agent || id).to_s, timeout: timeout)
74
+ end
75
+
76
+ # Boot the control UI (/studio) + the drop-in API (/v1) with EVERY agent of
77
+ # the system served — each agent's id is a `model` on /v1/responses.
78
+ # With workflows declared, `GET /v1/workflows` + `POST /v1/workflows/:name`
79
+ # are exposed too.
80
+ def serve(port: 9292, host: "localhost", token: nil, **opts)
81
+ runtime.serve(port: port, host: host, token: token, **opts)
82
+ end
83
+
84
+ # The live runtime graph with every pack imported. Memoized.
85
+ def runtime
86
+ @runtime ||= begin
87
+ require_relative "runtime"
88
+ Runtime.new(self, backend: @backend)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module DSL
5
+ # Adapts a DSL `workflow "name" do |input, ctx| … end` block to the engine's
6
+ # canonical workflow callable, `#call(input, context:, tools:)`.
7
+ #
8
+ # The engine contract is unchanged — this only decides what the AUTHOR holds.
9
+ # A workflow's job is orchestrating agent turns, so the second argument is a
10
+ # small context whose main verb is `ask`; the raw `context`/`tools` the
11
+ # Executor passes stay reachable for the rare workflow that wants them.
12
+ class WorkflowAdapter
13
+ def initialize(block:, runtime:)
14
+ @block = block
15
+ @runtime = runtime
16
+ end
17
+
18
+ def call(input, context:, tools:)
19
+ ctx = Context.new(runtime: @runtime, context: context, tools: tools)
20
+ @block.arity == 1 ? @block.call(input) : @block.call(input, ctx)
21
+ end
22
+
23
+ # What a workflow block receives. Deliberately tiny: the value of a workflow
24
+ # is deterministic Ruby AROUND model calls, not a framework.
25
+ class Context
26
+ # `context` is the turn's ContextPackage; `tools` are the resolved,
27
+ # enveloped tool instances (already filtered by the agent's policy).
28
+ attr_reader :context, :tools
29
+
30
+ def initialize(runtime:, context:, tools:)
31
+ @runtime = runtime
32
+ @context = context
33
+ @tools = tools
34
+ end
35
+
36
+ # One turn against one agent of the system → its text.
37
+ #
38
+ # Stateless by design (no session): a workflow step is a unit of work, not
39
+ # a conversation, and threading a session here would also drag in the
40
+ # serving teardown path. Each step is its own Task, so every step of a
41
+ # workflow is separately visible in the Studio and the trace.
42
+ def ask(agent, message, timeout: nil)
43
+ @runtime.chat(message.to_s, agent: agent.to_s, timeout: timeout)
44
+ end
45
+
46
+ # Runs blocks CONCURRENTLY on the turn's reactor and returns their values
47
+ # IN ORDER — the fan-out/fan-in step. Thin pass-through to the engine's
48
+ # own helper so a workflow does not hand-roll Async.
49
+ #
50
+ # sec, perf = ctx.gather(-> { ctx.ask("security", code) },
51
+ # -> { ctx.ask("performance", code) })
52
+ def gather(*blocks, max: 8)
53
+ require_relative "../tools/concurrency"
54
+ Insika::Tools::Concurrency.gather(*blocks, max: max)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end