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,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # Assembles the turn's chat (pipeline stages 5-7): instructions, eager/deferred
7
+ # tool partition, system tools (tool_search/load_skill/remember),
8
+ # history and callbacks. Extracted from the Executor so it can coordinate the
9
+ # pipeline without also carrying the RubyLLM glue.
10
+ #
11
+ # The Executor creates the chat (stage 6, the RubyLLM boundary) and passes it
12
+ # here to be configured; event numbering (monotonic seq per task) stays in the
13
+ # Executor, injected as the `emit` callable.
14
+ class ChatBuilder
15
+ def initialize(tool_registry:, skill_catalog:, checkpoint_store:, event_stream:,
16
+ hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil,
17
+ tool_trace_store: nil)
18
+ @tool_registry = tool_registry
19
+ @skill_catalog = skill_catalog
20
+ @checkpoint_store = checkpoint_store
21
+ @event_stream = event_stream
22
+ @hooks = hooks
23
+ @tool_catalog = tool_catalog
24
+ @memory_store = memory_store
25
+ # only to trace load_skill, which is not enveloped — nil = no trace (parity).
26
+ @tool_trace_store = tool_trace_store
27
+ # the object exposing #run_subagent (the Executor). nil = the
28
+ # spawn_subagent system tool is never wired (parity for a builder used
29
+ # without delegation, e.g. some unit stubs).
30
+ @subagent_runner = subagent_runner
31
+ end
32
+
33
+ # Configures an already-created chat with the context (stage 2) and the
34
+ # Resolution (stage 3), seeds the history and wires the callbacks. `emit` is
35
+ # the Executor's emitter (seq+task correlation), called as emit.call(type,
36
+ # data). The system tools (Tools::ToolSearch/LoadSkill/Remember) were already
37
+ # lazy-loaded by Executor#create_chat before reaching here.
38
+ def assemble(chat, state, emit:)
39
+ configure_chat(chat, state)
40
+ seed_history(chat, Array(state.context.history))
41
+ wire_callbacks(chat, state, emit)
42
+ chat
43
+ end
44
+
45
+ # Assembles the chat with the context (stage 2) and the Resolution's tools (stage 3).
46
+ def configure_chat(chat, state)
47
+ system = state.context.system.to_s
48
+ apply_instructions(chat, system, state) unless system.empty?
49
+
50
+ tools = Array(state.allowed_tools).dup
51
+
52
+ # Tool Search: the partition only runs with @tool_catalog present (parity
53
+ # when nil — the `&&` short-circuits before reading `.name`).
54
+ # `deferred_allowed` = allowed_tools ∩ tools_deferred. The <available_tools>
55
+ # catalog comes from Context::Providers::ToolSearch (stage 2); here we only
56
+ # decide chat.tools.
57
+ deferred_allowed = if @tool_catalog
58
+ Array(state.profile.tools_deferred).map(&:to_s) &
59
+ tools.map { |t| t.name.to_s }
60
+ else
61
+ []
62
+ end
63
+
64
+ unless deferred_allowed.empty?
65
+ tools.reject! { |t| deferred_allowed.include?(t.name.to_s) }
66
+ # a system tool (outside the allowlist), like load_skill — never enveloped.
67
+ tools << Tools::ToolSearch.new(@tool_catalog, deferred_allowed, chat,
68
+ tool_registry: @tool_registry,
69
+ checkpoint_store: @checkpoint_store,
70
+ event_stream: @event_stream, state: state)
71
+ end
72
+
73
+ # load_skill is a system default (outside the allowlist), otherwise
74
+ # progressive disclosure breaks. allowed_skills comes from the Resolution
75
+ # (policy), minus the EAGER ones: their bodies are already in the prompt, so a
76
+ # call could only pay for a duplicate. Nothing lazy left -> the tool is not
77
+ # wired at all. Keeping it for the discretionary skills is deliberate: that
78
+ # call is the only record of which skill the model actually reached for.
79
+ skill_names = lazy_skill_names(state)
80
+ unless skill_names.empty?
81
+ tools << Tools::LoadSkill.new(@skill_catalog, skill_names,
82
+ trace_recorder: @tool_trace_store, state: state,
83
+ agent: state.profile.id)
84
+ end
85
+
86
+ # remember is the memory-write system tool — wired only with
87
+ # @memory_store present AND profile.memory (a double gate). Never enveloped.
88
+ if @memory_store && state.profile.memory
89
+ tools << Tools::Remember.new(@memory_store, state.tenant,
90
+ event_stream: @event_stream, state: state)
91
+ end
92
+
93
+ # signal_stuck is the "I cannot proceed" system tool (WS5) — wired only
94
+ # when the agent opted in (`profile.stuck_signal`), never enveloped. It is a
95
+ # deterministic signal; the consumer decides what "stuck" means. Defensive
96
+ # read: a minimal profile double without the reader means off (nil = parity).
97
+ if state.profile.respond_to?(:stuck_signal) && state.profile.stuck_signal
98
+ tools << Tools::StuckSignal.new(state: state)
99
+ end
100
+
101
+ # spawn_subagent is the delegation system tool — wired only with a
102
+ # runner present AND profile.subagents non-empty (a double gate, like
103
+ # remember). Never enveloped: in the synchronous mode the child lives in the
104
+ # parent's envelope. The runtime gate on WHICH agent is spawnable is the
105
+ # parent's subagents allowlist, enforced in Executor#run_subagent.
106
+ if @subagent_runner && !Array(state.profile.subagents).empty?
107
+ tools << Tools::Subagent.new(runner: @subagent_runner, state: state)
108
+ # and its parallel sibling: fan-out N children at once.
109
+ tools << Tools::Subagents.new(runner: @subagent_runner, state: state)
110
+ end
111
+
112
+ unless tools.empty?
113
+ # the ONLY place the gem is told to run tool calls in parallel.
114
+ # `:fibers` is not a preference but the only admissible mode — `:threads`
115
+ # breaks ToolEnvelope's `Async::Task.current.with_timeout`, the SQLite
116
+ # store's fiber semaphore, and the turn's own durability (mailbox,
117
+ # approvals, cancellation are all expressed in fiber terms). The number
118
+ # the operator configured is OUR cap (ToolAssembly#install_tool_gate);
119
+ # the gem has none.
120
+ if tool_concurrency_for(state)
121
+ chat.with_tools(*tools, concurrency: :fibers)
122
+ else
123
+ chat.with_tools(*tools)
124
+ end
125
+ end
126
+
127
+ chat
128
+ end
129
+
130
+ # The skills the model still has to ask for: the Resolution's set minus the eager
131
+ # ones. Intersected by NAME against the catalog's own verdict (SkillCatalog#eager_for)
132
+ # so the tool and the level-1 list can never disagree about who is eager. A catalog
133
+ # without the reader (a unit stub) falls back to the whole set — parity.
134
+ def lazy_skill_names(state)
135
+ names = Array(state.allowed_skills).map { |s| s.respond_to?(:name) ? s.name : s.to_s }
136
+ return names unless @skill_catalog.respond_to?(:eager_for)
137
+
138
+ eager = @skill_catalog.eager_for(state.profile).map(&:name)
139
+ names - eager
140
+ end
141
+
142
+ # The turn's effective tool concurrency (nil = serial), plus the ONE thing the
143
+ # gate owes the operator: when the profile asked for parallel tool calls and
144
+ # this turn silently cannot have them (an approval-required tool would
145
+ # deadlock two fibers on the single per-task mailbox), say so once. Otherwise
146
+ # the speedup just vanishes with no reason given. The rule itself lives in
147
+ # TurnState; a state predating those readers (a unit stub) means off.
148
+ def tool_concurrency_for(state)
149
+ return nil unless state.respond_to?(:tool_concurrency)
150
+
151
+ effective = state.tool_concurrency
152
+ return effective if effective
153
+
154
+ requested = state.requested_tool_concurrency
155
+ warn_tool_concurrency_gated(state, requested) if requested
156
+ nil
157
+ end
158
+
159
+ def warn_tool_concurrency_gated(state, requested)
160
+ gated = Array(state.requires_approval)
161
+ @event_stream.emit(Insika::Event.new(
162
+ type: :provider_warning,
163
+ data: { provider: "tool_concurrency",
164
+ message: "parallel tool calls (#{requested}) disabled for this turn: " \
165
+ "#{gated.size} tool(s) require approval (#{gated.join(', ')})" },
166
+ meta: { task_id: state.task&.id, at: Time.now.utc.iso8601 }
167
+ ))
168
+ end
169
+
170
+ # R3: opt-in Anthropic prompt caching. When the agent enables
171
+ # prompt_caching AND the resolved provider is Anthropic, wrap the system in
172
+ # the provider's native Content helper with cache: true — ONE breakpoint at
173
+ # the END of the system block. By Anthropic's prefix order
174
+ # (tools -> system -> messages), a breakpoint on the last system block caches
175
+ # tools + system together and is immune to history eviction (messages come
176
+ # after it). RubyLLM::Content::Raw is Anthropic-specific: build_system_content
177
+ # emits its blocks verbatim, so the cache_control rides along.
178
+ #
179
+ # Any other case (caching off, or a non-Anthropic provider) uses the plain
180
+ # string — OpenAI caches its prefix on its own; the Raw shape would confuse
181
+ # non-Anthropic providers. The gem only supports MANUAL caching, and only for
182
+ # Anthropic.
183
+ #
184
+ # PRE-AUDIT (why this is opt-in): the system must be BYTE-STABLE between turns
185
+ # for a read hit. A context provider that injects volatile content into
186
+ # :system (timestamps, per-turn data) makes every turn a paid cache WRITE
187
+ # with no hit — worse than off. Enable only for stable-system agents.
188
+ def apply_instructions(chat, system, state)
189
+ if state.profile.prompt_caching && anthropic_provider?(chat)
190
+ chat.with_instructions(RubyLLM::Providers::Anthropic::Content.new(system, cache: true))
191
+ else
192
+ chat.with_instructions(system)
193
+ end
194
+ end
195
+
196
+ # The RESOLVED provider (chat.model.provider is the slug string, e.g.
197
+ # "anthropic"), authoritative even when the agent left provider nil and
198
+ # RubyLLM inferred it from the model id. Any surface without a model (fakes,
199
+ # a provider that raises) -> false: caching silently stays off.
200
+ def anthropic_provider?(chat)
201
+ chat.respond_to?(:model) && chat.model && chat.model.provider.to_s == "anthropic"
202
+ rescue StandardError
203
+ false
204
+ end
205
+
206
+ # History comes from the context/checkpoint. The {role:, content:} shape
207
+ # tolerates string keys (JSON from the stores). `flatten(1)` dissolves the
208
+ # Session provider's "eviction units" (an assistant+tool_results cycle grouped
209
+ # as one Array, R1) back into a flat message stream.
210
+ #
211
+ # tool_calls / tool_call_id are rehydrated ONLY when present, so a message
212
+ # without them keeps the 2-arg shape the specs' FakeChat expects (no unknown
213
+ # keyword). This is what lets the model SEE the tools it already called.
214
+ def seed_history(chat, messages)
215
+ Array(messages).flatten(1).each do |m|
216
+ attrs = { role: (m[:role] || m["role"]).to_sym, content: m[:content] || m["content"] }
217
+ tool_calls = m[:tool_calls] || m["tool_calls"]
218
+ tool_call_id = m[:tool_call_id] || m["tool_call_id"]
219
+ attrs[:tool_calls] = rehydrate_tool_calls(tool_calls) if tool_calls && !Array(tool_calls).empty?
220
+ attrs[:tool_call_id] = tool_call_id if tool_call_id
221
+ chat.add_message(**attrs)
222
+ end
223
+ end
224
+
225
+ # [{id,name,arguments}] (string|symbol keys) -> {id => RubyLLM::ToolCall}, the
226
+ # shape RubyLLM seeds an assistant message with. Called only when the gem is
227
+ # already loaded (create_chat required it before assemble).
228
+ def rehydrate_tool_calls(list)
229
+ Array(list).each_with_object({}) do |tc, acc|
230
+ id = (tc[:id] || tc["id"]).to_s
231
+ acc[id] = RubyLLM::ToolCall.new(
232
+ id: id, name: (tc[:name] || tc["name"]).to_s,
233
+ arguments: tc[:arguments] || tc["arguments"] || {}
234
+ )
235
+ end
236
+ end
237
+
238
+ # RubyLLM's additive callbacks become events. load_skill becomes
239
+ # :skill_activated. Adds the max_tool_calls counter: the loop is RubyLLM's;
240
+ # here we only count and abort.
241
+ def wire_callbacks(chat, state, emit)
242
+ # Per-TURN counter: safe as a closure local even under concurrent tool calls
243
+ # (MRI fibers do not preempt between the read and the write). The per-CALL
244
+ # correlation is NOT — it lives in fiber storage behind TurnState, because
245
+ # each call gets its own fiber once tool concurrency is on.
246
+ tool_calls = 0
247
+ max_tool_calls = state.profile.limits[:max_tool_calls] || 50
248
+
249
+ # the loop detector. Needs #after_message + #add_message for the
250
+ # batch-boundary intervention; a chat without them (smoke shim, minimal
251
+ # double) stays bounded by max_tool_calls alone — never half-wired.
252
+ detector = if %i[after_message add_message].all? { |m| chat.respond_to?(m) }
253
+ repeat = state.profile.limits[:max_tool_repeat] || Insika::AgentProfile::DEFAULT_LIMITS[:max_tool_repeat]
254
+ Insika::LoopDetector.new(chat: chat, limit: repeat, emit: emit) if repeat >= 2
255
+ end
256
+
257
+ chat.before_tool_call do |tool_call|
258
+ # call<->decorator correlation (side-effects/skip) — 1st line.
259
+ state.current_tool_call = tool_call
260
+ # max_tool_calls guard-rail: stays inline (not as a registered hook)
261
+ # because Hooks is shared across turns and has no unregister.
262
+ tool_calls += 1
263
+ if tool_calls > max_tool_calls
264
+ raise Insika::TimeoutError.new("tool call limit exceeded (#{max_tool_calls})",
265
+ stage: :tool_limit)
266
+ end
267
+
268
+ # AFTER the count, BEFORE the call runs — a post-warning
269
+ # repeat raises here, so the stubborn loop pays for no extra call.
270
+ detector&.tool_call(tool_call.name, tool_call.arguments)
271
+
272
+ # :tool pair: RubyLLM's callbacks are additive — the altered subject
273
+ # feeds later hooks and the events, but does not rewrite the call the
274
+ # model executes. A hook exception here aborts the turn.
275
+ tool_call = @hooks.run_before(:tool, tool_call)
276
+
277
+ # The name of the (possibly hook-altered) subject, for the :tool_result
278
+ # label. Also fiber-scoped: as a closure local it belonged to the TURN, so
279
+ # under concurrency `after_tool_result` labelled every result with whichever
280
+ # call started last.
281
+ state.current_tool_name = tool_call.name.to_s
282
+ if state.current_tool_name == "load_skill"
283
+ args = tool_call.arguments || {}
284
+ emit.call(:skill_activated, { name: args["name"] || args[:name] })
285
+ else
286
+ emit.call(:tool_call, { name: tool_call.name, arguments: tool_call.arguments })
287
+ end
288
+ end
289
+
290
+ chat.after_tool_result do |result|
291
+ # the RAW result — the only place a Tool::Halt (halt_when) is
292
+ # still recognizable, and a halted batch must receive no intervention.
293
+ detector&.tool_result(result)
294
+ result = @hooks.run_after(:tool, result)
295
+ emit.call(:tool_result, { name: state.current_tool_name, result: result.to_s })
296
+ end
297
+
298
+ # the intervention appends at the batch boundary (the Nth tool
299
+ # result closing) — never between two tool results of one batch.
300
+ chat.after_message { |message| detector.message_ended(message) } if detector
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Per-turn snapshot. The checkpoint of
5
+ # turn n holds the state AT THE START of turn n — resuming = re-executing the
6
+ # whole turn n. `messages` is the MATERIALIZED transcript (not a cursor): the
7
+ # checkpoint is self-contained and resumption does not depend on the Session Store.
8
+ #
9
+ # `completed_side_effects`: ids of non-idempotent tool calls already completed
10
+ # within the turn (resumption answers them with "already_executed").
11
+ Checkpoint = Data.define(:task_id, :turn, :session_id, :agent_id,
12
+ :messages, :completed_side_effects, :created_at)
13
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # Domain store for checkpoints. A per-turn snapshot written
7
+ # in an ALL-OR-NOTHING transaction (invariant: a checkpoint is either fully
8
+ # valid or does not exist), a record of non-idempotent side effects in a spill
9
+ # key during the turn, and `prune` to bound growth.
10
+ #
11
+ # Two key families in the "checkpoints" scope:
12
+ # "checkpoint:<task_id>:turn:<n>" -> Checkpoint JSON
13
+ # "sideeffects:<task_id>:turn:<n>" -> ["tool_call_id", ...] (spill key)
14
+ #
15
+ # The spill key exists because the turn's checkpoint does not exist yet when
16
+ # the tool calls run (it is only saved at stage 8): it is written BEFORE the
17
+ # tool result goes back to the model, and the following `save` consolidates it
18
+ # into `completed_side_effects` and deletes it in the SAME transaction.
19
+ class CheckpointStore
20
+ include Coercion
21
+
22
+ SCOPE = "checkpoints"
23
+
24
+ def initialize(store:)
25
+ @store = store
26
+ end
27
+
28
+ # -> Checkpoint (with the consolidated side-effect list). ALWAYS in a
29
+ # transaction. Order: validate monotonicity -> consolidate the previous
30
+ # turn's spill key -> write the checkpoint -> delete the absorbed spill key.
31
+ # Any exception in the middle -> full rollback (neither a partial checkpoint
32
+ # nor a lost spill key).
33
+ def save(checkpoint)
34
+ @store.transaction do
35
+ current = latest(checkpoint.task_id)
36
+ if current && current.turn >= checkpoint.turn
37
+ raise ArgumentError,
38
+ "checkpoint with non-monotonic turn: #{checkpoint.turn} <= #{current.turn}"
39
+ end
40
+
41
+ # Stage 8 of turn n saves turn n+1's checkpoint:
42
+ # the spill key to absorb is that of the turn that just executed (n).
43
+ spill_key = sideeffects_key(checkpoint.task_id, checkpoint.turn - 1)
44
+ spilled = @store.get(SCOPE, spill_key) || []
45
+ consolidated = Array(checkpoint.completed_side_effects).map(&:to_s) | spilled
46
+
47
+ record = deep_stringify(checkpoint.to_h)
48
+ record["completed_side_effects"] = consolidated
49
+ record["created_at"] ||= timestamp
50
+ @store.set(SCOPE, checkpoint_key(checkpoint.task_id, checkpoint.turn), record)
51
+ @store.delete(SCOPE, spill_key)
52
+
53
+ to_checkpoint(record)
54
+ end
55
+ end
56
+
57
+ # -> Checkpoint | nil (highest turn). NUMERIC ordering: `list` sorts
58
+ # lexicographically and "turn:9" > "turn:10" — parse n as an Integer.
59
+ def latest(task_id)
60
+ turns = checkpoint_turns(task_id)
61
+ return nil if turns.empty?
62
+
63
+ find(task_id, turn: turns.max)
64
+ end
65
+
66
+ # -> Checkpoint | nil
67
+ def find(task_id, turn:)
68
+ record = @store.get(SCOPE, checkpoint_key(task_id, turn))
69
+ record && to_checkpoint(record)
70
+ end
71
+
72
+ # -> nil; idempotent (recording twice = one entry). In a transaction
73
+ # (written before the tool goes back to the model).
74
+ def record_side_effect(task_id, turn:, tool_call_id:)
75
+ @store.transaction do
76
+ key = sideeffects_key(task_id, turn)
77
+ ids = @store.get(SCOPE, key) || []
78
+ id = tool_call_id.to_s
79
+ @store.set(SCOPE, key, ids + [id]) unless ids.include?(id)
80
+ end
81
+ nil
82
+ end
83
+
84
+ # -> [tool_call_id] = spill key ∪ checkpoint of the same turn.
85
+ # Covers both places where an id may live during the cycle; since
86
+ # tool_call_id is globally unique, the union never causes an improper skip.
87
+ def side_effects(task_id, turn:)
88
+ spilled = @store.get(SCOPE, sideeffects_key(task_id, turn)) || []
89
+ from_checkpoint = find(task_id, turn: turn)&.completed_side_effects || []
90
+ spilled | from_checkpoint
91
+ end
92
+
93
+ # -> void. Keeps the `keep` checkpoints with the highest turn (numeric); deletes the
94
+ # rest. Also clears spill keys of turns strictly smaller than the smallest
95
+ # kept turn (unreachable garbage after consolidation). In a transaction
96
+ # so it never leaves a partial prune. No-op if there are <= keep checkpoints.
97
+ def prune(task_id, keep: 1)
98
+ @store.transaction do
99
+ turns = checkpoint_turns(task_id).sort
100
+ next if turns.size <= keep
101
+
102
+ kept = turns.last(keep)
103
+ smallest_kept = kept.first
104
+ (turns - kept).each { |n| @store.delete(SCOPE, checkpoint_key(task_id, n)) }
105
+ sideeffect_turns(task_id).each do |n|
106
+ @store.delete(SCOPE, sideeffects_key(task_id, n)) if n < smallest_kept
107
+ end
108
+ end
109
+ nil
110
+ end
111
+
112
+ private
113
+
114
+ def checkpoint_key(task_id, turn)
115
+ "checkpoint:#{task_id}:turn:#{turn}"
116
+ end
117
+
118
+ def sideeffects_key(task_id, turn)
119
+ "sideeffects:#{task_id}:turn:#{turn}"
120
+ end
121
+
122
+ def checkpoint_turns(task_id)
123
+ @store.list(SCOPE, "checkpoint:#{task_id}:turn:").map { |k| turn_of(k) }
124
+ end
125
+
126
+ def sideeffect_turns(task_id)
127
+ @store.list(SCOPE, "sideeffects:#{task_id}:turn:").map { |k| turn_of(k) }
128
+ end
129
+
130
+ # n is the last segment of the key "...:turn:<n>"; parse it as an Integer.
131
+ def turn_of(key)
132
+ key.split(":").last.to_i
133
+ end
134
+
135
+ # Materializes at the edge: `turn` as an Integer; the other fields as they come from the
136
+ # backend (string keys in `messages`).
137
+ def to_checkpoint(record)
138
+ Checkpoint.new(
139
+ task_id: record["task_id"],
140
+ turn: record["turn"].to_i,
141
+ session_id: record["session_id"],
142
+ agent_id: record["agent_id"],
143
+ messages: record["messages"],
144
+ completed_side_effects: record["completed_side_effects"],
145
+ created_at: record["created_at"]
146
+ )
147
+ end
148
+
149
+ def timestamp
150
+ Time.now.utc.iso8601
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # The circuit breaker's durable state (WS3): one cell per
7
+ # (tenant, provider/model ref) holding the recent failures and when the
8
+ # circuit opened. The read-modify-write rides `@store.transaction` — the same
9
+ # discipline as the dispatch claim (A1): two concurrent failures of the same
10
+ # cell serialize, so the "10 failures in 60s" count is never lost to a race.
11
+ #
12
+ # States, standard semantics:
13
+ # :closed fewer than `after` failures in the `within` window — attempts flow.
14
+ # :open the window count is met AND the cooldown hasn't elapsed — the
15
+ # edge FAIL-FASTS (no provider call) with retry_after = remaining
16
+ # cooldown. Reached at the `after`-th failure, which stamps
17
+ # opened_at.
18
+ # :half_open the cooldown elapsed — the next attempt is the TRIAL: a success
19
+ # closes (record_success clears the cell), a failure reopens
20
+ # (a new failure count / opened_at).
21
+ class CircuitState
22
+ SCOPE = "circuit_state"
23
+
24
+ # Timestamps are pruned to COUNT_LIMIT — a pathological loop cannot grow the
25
+ # cell unboundedly (the window is 60s-wide; 100 reads and writes bounded).
26
+ COUNT_LIMIT = 100
27
+
28
+ Record = Data.define(:failures, :opened_at)
29
+
30
+ def initialize(store:)
31
+ @store = store
32
+ end
33
+
34
+ # Records ONE failure for (tenant, ref). If this failure makes the window
35
+ # reach `after` and the circuit is not already open, it stamps opened_at
36
+ # (the instant the breaker trips). -> :closed (still closed) | :open (JUST
37
+ # tripped — closed->open, the ONLY transition that alerts) | :reopened (an
38
+ # already-tripped cell re-stamped: a half-open trial failed; the circuit is
39
+ # open again but the node already told the operator it is sick — no NEW
40
+ # alert (WS3)).
41
+ def record_failure(tenant:, ref:, after: 10, within: 60, now: Time.now)
42
+ key = key_for(tenant, ref)
43
+ @store.transaction do
44
+ record = load(key)
45
+ cutoff = (now.to_i - within)
46
+ retained = record.failures.select { |t| t > cutoff }.last(COUNT_LIMIT)
47
+ failures = (retained + [now.to_i]).last(COUNT_LIMIT)
48
+ opened_at = record.opened_at
49
+ tripped = :closed
50
+ if opened_at.nil?
51
+ if failures.size >= after
52
+ opened_at = now.to_i
53
+ tripped = :open
54
+ end
55
+ else
56
+ # the circuit was tripped before; a failure here can only be a HALF-OPEN
57
+ # trial that failed. Re-stamp opened_at so it RE-OPENS — a breaker that
58
+ # never restamps is one-shot: after the first cooldown every later turn
59
+ # is an unlocked half-open trial (WS3).
60
+ opened_at = now.to_i
61
+ tripped = :reopened
62
+ end
63
+ @store.set(SCOPE, key, { "failures" => failures, "opened_at" => opened_at })
64
+ tripped
65
+ end
66
+ end
67
+
68
+ # A successful attempt CLOSES the circuit: the cell is cleared so the
69
+ # failure window starts fresh (half-open trial success included).
70
+ def record_success(tenant:, ref:)
71
+ @store.delete(SCOPE, key_for(tenant, ref))
72
+ end
73
+
74
+ # -> :closed | :open | :half_open
75
+ def state(tenant:, ref:, after: 10, within: 60, cooldown: 300, now: Time.now)
76
+ record = load(key_for(tenant, ref))
77
+ opened = record.opened_at.to_i
78
+ # :closed needs BOTH doors shut: the window count is under `after` AND the
79
+ # circuit never tripped. A surviving opened_at tombstone keeps the circuit
80
+ # governed by the cooldown (open, then half-open) even after its original
81
+ # failures age out of the rolling window — only a SUCCESS clears it (WS3:
82
+ # a failed half-open trial must reopen, not silently close).
83
+ return :closed if record.failures.size < after && opened.zero?
84
+
85
+ return :open if opened.zero? || (now.to_i - opened) < cooldown
86
+
87
+ :half_open
88
+ end
89
+
90
+ # Seconds until the circuit can be retried (the remaining cooldown). nil
91
+ # while closed.
92
+ def retry_after(tenant:, ref:, cooldown: 300, now: Time.now)
93
+ record = load(key_for(tenant, ref))
94
+ return nil if record.failures.empty?
95
+
96
+ opened = record.opened_at.to_i
97
+ return nil if opened.zero?
98
+
99
+ remaining = cooldown - (now.to_i - opened)
100
+ remaining.positive? ? remaining : nil
101
+ end
102
+
103
+ private
104
+
105
+ def key_for(tenant, ref) = "#{tenant || 'platform'}:#{ref}"
106
+
107
+ def load(key)
108
+ record = @store.get(SCOPE, key)
109
+ return Record.new([], nil) if record.nil?
110
+
111
+ Record.new(Array(record["failures"]), record["opened_at"])
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Shared coercions at the input/persistence boundary. They used to live
5
+ # copied in each store/handler; a single home keeps them from drifting.
6
+ module Coercion
7
+ module_function
8
+
9
+ # Present string or nil: nil and blank (incl. whitespace-only) become nil;
10
+ # everything else becomes a stripped String.
11
+ def presence(str)
12
+ s = str.to_s.strip
13
+ s.empty? ? nil : s
14
+ end
15
+
16
+ # nil or blank (incl. whitespace-only) -> true.
17
+ def blank?(value) = value.nil? || value.to_s.strip.empty?
18
+
19
+ # Inverse of blank? — a usable, non-empty value.
20
+ def present?(value) = !blank?(value)
21
+
22
+ # The operator-facing truthy: what a form checkbox ("1"), a JSON round-trip
23
+ # ("true") or the DSL (true) produce for an opt-in. One list, one reading —
24
+ # AgentProfile#stream_public?, SkillCatalog#blanket? and the Studio all consult
25
+ # it, so a value that opts in on one surface cannot opt out on another.
26
+ TRUTHY = [true, "true", "1", "yes", "on"].freeze
27
+
28
+ def truthy?(value) = TRUTHY.include?(value)
29
+
30
+ # Bytes that come from outside the engine (sockets, pipes, subprocesses)
31
+ # arrive tagged BINARY, or as UTF-8 carrying invalid sequences. Both break
32
+ # `JSON.generate` the moment the text has an accent or an emoji — today a
33
+ # warning ("UTF-8 string passed as BINARY"), an exception from json 3.0 — and
34
+ # that text ends up in a transcript, an event and an SSE frame. Reinterprets
35
+ # the bytes as UTF-8 (the wire encoding of every source we read) and scrubs
36
+ # what is not valid, so what crosses the boundary is always serializable.
37
+ def utf8(str)
38
+ s = str.to_s
39
+ s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
40
+ s.valid_encoding? ? s : s.scrub
41
+ end
42
+
43
+ # Normalizes keys and Symbols to String recursively (the stores' JSON model
44
+ # has no Symbol). Hash -> keys and values; Array -> elements.
45
+ def deep_stringify(obj)
46
+ case obj
47
+ when Hash
48
+ obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = deep_stringify(v) }
49
+ when Array
50
+ obj.map { |v| deep_stringify(v) }
51
+ when Symbol
52
+ obj.to_s
53
+ else
54
+ obj
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Every mutating interaction becomes a Command. Shared shape:
8
+ # type: Symbol (:create_session, :send_message, :trigger_workflow,
9
+ # :cancel_task, :resume_task)
10
+ # payload: Hash validated by the handler
11
+ # meta: { command_id:, tenant:, transport:, issued_at: }
12
+ #
13
+ # `Command` does not validate payload (that's the handler's job) and does not
14
+ # know about the bus.
15
+ Command = Data.define(:type, :payload, :meta) do
16
+ # Factory that fills in the meta defaults. `type` is
17
+ # normalized to Symbol; a nil `payload` becomes {} (the handler decides
18
+ # whether required fields are missing).
19
+ def self.build(type, payload = {}, transport: :internal, tenant: nil)
20
+ new(
21
+ type: type.to_sym,
22
+ payload: payload || {},
23
+ meta: {
24
+ command_id: SecureRandom.uuid,
25
+ tenant: tenant,
26
+ transport: transport,
27
+ issued_at: Time.now.utc.iso8601
28
+ }
29
+ )
30
+ end
31
+ end
32
+ end