insika 0.8.0 → 1.0.1

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -0,0 +1,16 @@
1
+ <%# Avatar + name + mono sub-line: the unit every list of agents, customers
2
+ and sessions is built from. Locals:
3
+ name: — the human-readable label (truncates if long)
4
+ sub: — the mono second line, usually short_id(...) or a model name
5
+ id: — the stable identifier the avatar hue is derived from, so the
6
+ same subject keeps its colour across pages and renders.
7
+ The initial comes from the name when there is one, else from the id — an
8
+ unnamed row still gets a mark rather than an empty circle. %>
9
+ <% mark = (name.to_s.strip.empty? ? id.to_s : name.to_s)[0, 1] %>
10
+ <span class="identity">
11
+ <span class="av av-<%= avatar_hue(id) %>" aria-hidden="true"><%= mark %></span>
12
+ <span class="who">
13
+ <span class="name"><%= name %></span>
14
+ <% if sub %><span class="id"><%= sub %></span><% end %>
15
+ </span>
16
+ </span>
@@ -0,0 +1,17 @@
1
+ <%# One metric in a `.kpi-strip`. Locals:
2
+ label: — what is being counted (rendered uppercase by the CSS)
3
+ value: — the number itself
4
+ sub: — optional qualifier under the value ("last 7 days")
5
+ delta: — optional change vs the previous period, e.g. "+12%"; a leading
6
+ "-" colors it red, anything else green. Pass nil for "no
7
+ comparison available" — an absent delta is not a zero delta. %>
8
+ <% sub = defined?(sub) ? sub : nil %>
9
+ <% delta = defined?(delta) ? delta : nil %>
10
+ <div class="kpi">
11
+ <span class="label"><%= label %></span>
12
+ <span class="value tnum"><%= value %></span>
13
+ <% if delta %>
14
+ <span class="delta <%= delta.to_s.start_with?("-") ? "down" : "up" %>"><%= delta %></span>
15
+ <% end %>
16
+ <% if sub %><span class="sub"><%= sub %></span><% end %>
17
+ </div>
@@ -12,7 +12,7 @@
12
12
  <% if role == "tool" %>
13
13
  <% artifact_url = artifact_link_in(m["content"]) %>
14
14
  <% if artifact_url %>
15
- <p class="artifact-chip"><%== chip_icon(:artifact) %><a class="btn sm" href="<%= artifact_url %>" target="_blank" rel="noopener">open artifact</a></p>
15
+ <p class="artifact-chip"><%== chip_icon(:artifact) %><a class="btn btn-sm" href="<%= artifact_url %>" target="_blank" rel="noopener">open artifact</a></p>
16
16
  <% end %>
17
17
  <details class="toolcard result">
18
18
  <summary><%== chip_icon(:result) %><strong>tool result</strong><% unless at.empty? %><time class="stamp" datetime="<%= m["at"] %>"><%= at %></time><% end %></summary>
@@ -45,10 +45,45 @@
45
45
  </div>
46
46
  <% if show_bubble %>
47
47
  <div class="bubble">
48
- <div class="content<%== ' md' if role == 'assistant' %>"<%== ' data-controller="markdown"' if role == 'assistant' %>><%= message_content(m["content"]) %></div>
48
+ <div class="content<%== ' md' if role == 'assistant' %>"<%== ' data-controller="markdown"' if role == 'assistant' %>><% if role == "user" %><%== mark_tags(message_content(m["content"])) %><% else %><%= message_content(m["content"]) %><% end %></div>
49
49
  </div>
50
50
  <% end %>
51
51
  </div>
52
+ <%# Guardrail flags the input check raised on this message (persisted with it,
53
+ so they survive a reload) — same chip the live :guardrail_flagged event draws. %>
54
+ <% review = defined?(review) ? review : nil %>
55
+ <% Array(m["guardrail_flags"]).each_with_index do |f, fi| %>
56
+ <details class="toolcard guardrail" open>
57
+ <summary><%== chip_icon(:guardrail) %><strong>guardrail <%= f["action"] == "refuse" ? "blocked" : "flagged" %> · <%= f["category"] || "?" %></strong></summary>
58
+ <dl class="guardrail-why">
59
+ <% [["category", f["category"]], ["engine", f["source"]], ["action", f["action"]], ["matched", f["detail"]]].each do |label, value| %>
60
+ <% next if value.to_s.empty? %>
61
+ <dt><%= label %></dt><dd><%= value %></dd>
62
+ <% end %>
63
+ </dl>
64
+ <%# Session viewer only: report the flag as a false positive, or resolve it
65
+ (clears the conversation's flagged mark; the flag stays as history). %>
66
+ <% if review %>
67
+ <% state = f.dig("review", "status") %>
68
+ <form class="guardrail-review" method="post" action="<%= review[:path] %>">
69
+ <%== csrf_tag %>
70
+ <input type="hidden" name="message" value="<%= review[:message] %>">
71
+ <input type="hidden" name="flag" value="<%= fi %>">
72
+ <% if state == "reported" %>
73
+ <span class="status warn">reported as false positive</span>
74
+ <button type="submit" class="btn btn-sm" name="status" value="resolved">Mark resolved</button>
75
+ <button type="submit" class="btn btn-sm ghost">Undo</button>
76
+ <% elsif state == "resolved" %>
77
+ <span class="status ok">resolved</span>
78
+ <button type="submit" class="btn btn-sm ghost">Undo</button>
79
+ <% else %>
80
+ <button type="submit" class="btn btn-sm" name="status" value="reported">Mark false positive</button>
81
+ <button type="submit" class="btn btn-sm ghost" name="status" value="resolved">Resolve</button>
82
+ <% end %>
83
+ </form>
84
+ <% end %>
85
+ </details>
86
+ <% end %>
52
87
  <%# Tool calls render BETWEEN bubbles (chips), not inside them — the same
53
88
  reading order the live SSE stream shows: prose → call → result. %>
54
89
  <% calls.each do |tc| %>
@@ -56,6 +91,9 @@
56
91
  <details class="toolcard call">
57
92
  <summary><%== chip_icon(:call) %><strong><%= tc["name"] %></strong><span class="chip-sub"><%= preview[0, 60] %><%== "…" if preview.size > 60 %></span></summary>
58
93
  <pre><%= message_content(tc["arguments"]) %></pre>
94
+ <% if (loaded = loaded_skill_name({ "tool" => tc["name"], "args" => tc["arguments"] })) %>
95
+ <p class="skill-link"><a class="mono" href="/studio/skills/<%= Rack::Utils.escape(loaded) %>" data-turbo-frame="_top">open skill <%= loaded %></a></p>
96
+ <% end %>
59
97
  </details>
60
98
  <% end %>
61
99
  <% end %>
@@ -0,0 +1,82 @@
1
+ <% series = Array(@report && @report["series"]) %>
2
+ <% values = series.flat_map { |bucket| fields.map { |field, _label| bucket[field] } }.compact %>
3
+ <% has_values = !values.empty? && (unit != "requests" || values.sum.positive?) %>
4
+ <% ceiling = [values.max || 0, unit == "USD" ? 0.000001 : 1].max.to_f %>
5
+ <% count = [series.size, 1].max; slot = 600.0 / count %>
6
+ <% chart_id = "chart-#{title.downcase.gsub(/[^a-z0-9]+/, '-')}" %>
7
+ <% step = @report ? @report["step_seconds"].to_i : 0 %>
8
+ <% stamp_format = step < 86_400 ? "%b %-d · %H:%M UTC" : "%b %-d, %Y" %>
9
+ <%# the tooltip rows, formatted here so the chart and the table read the same %>
10
+ <% tips = series.map do |bucket|
11
+ rows = fields.map { |field, label| { "label" => label, "key" => field.delete_suffix("_ms"), "value" => bucket[field].nil? ? "No value" : model_chart_value(bucket[field], unit) } }
12
+ note = (fields.any? { |f, _| f == "cost" } && bucket["unknown_cost_requests"].to_i.positive?) ? "#{bucket['unknown_cost_requests']} requests with unknown cost" : nil
13
+ { "at" => Time.iso8601(bucket["at"]).utc.strftime(stamp_format), "requests" => (unit == "requests" ? nil : bucket["requests"].to_i), "rows" => rows, "note" => note }
14
+ end %>
15
+ <div class="model-plot-shell" data-controller="model-chart" data-model-chart-tips-value="<%= JSON.generate(tips) %>" data-model-chart-count-value="<%= series.size %>">
16
+ <div class="model-axis-y" aria-hidden="true">
17
+ <% [ceiling, ceiling / 2, 0].each do |tick| %><span><%= has_values ? model_chart_tick(tick, unit) : "—" %></span><% end %>
18
+ </div>
19
+ <div class="model-plot-body" data-model-chart-target="body" data-action="pointermove->model-chart#track pointerleave->model-chart#hide focusin->model-chart#focus focusout->model-chart#hide">
20
+ <svg class="model-plot" viewBox="0 0 600 180" preserveAspectRatio="none" role="img" aria-label="<%= title %>">
21
+ <title><%= title %></title>
22
+ <desc><%= unit %> by time interval. Missing measurements remain gaps. Exact values are available in the chart data table below.</desc>
23
+ <defs>
24
+ <% fields.each do |field, _label| %>
25
+ <linearGradient id="<%= chart_id %>-<%= field %>" class="model-gradient <%= unit == 'requests' ? 'requests' : field.delete_suffix('_ms') %>" x1="0" y1="0" x2="0" y2="1">
26
+ <stop offset="0" stop-color="currentColor" stop-opacity="<%= unit == 'requests' ? '.95' : '.28' %>"/>
27
+ <stop offset="1" stop-color="currentColor" stop-opacity="<%= unit == 'requests' ? '.45' : '0' %>"/>
28
+ </linearGradient>
29
+ <% end %>
30
+ </defs>
31
+ <% [0, 90, 180].each do |y| %><line class="model-gridline" x1="0" x2="600" y1="<%= y %>" y2="<%= y %>"/><% end %>
32
+ <line class="model-cursor" x1="0" x2="0" y1="0" y2="180" data-model-chart-target="cursor" vector-effect="non-scaling-stroke"/>
33
+ <% if has_values %>
34
+ <% fields.each_with_index do |(field, label), field_index| %>
35
+ <% color = field.delete_suffix("_ms") %>
36
+ <% points = series.each_with_index.map { |bucket, i| bucket[field].nil? ? nil : [(i + 0.5) * slot, 180 - bucket[field] / ceiling * 180] } %>
37
+ <% if unit != "requests" %>
38
+ <% model_chart_paths(points).each do |path| %>
39
+ <% if field_index == fields.size - 1 %><%# one area per chart, under the lowest line, so stacked latency stays readable %>
40
+ <% xs = path.scan(/[ML]([\d.]+) /).flatten %>
41
+ <path class="model-area" d="<%= path %> L<%= xs.last %> 180 L<%= xs.first %> 180 Z" fill="url(#<%= chart_id %>-<%= field %>)"/>
42
+ <% end %>
43
+ <path class="model-line <%= color %>" d="<%= path %>" vector-effect="non-scaling-stroke"/>
44
+ <% end %>
45
+ <% end %>
46
+ <% points.each_with_index do |point, i| %>
47
+ <% next unless point %>
48
+ <% x, y = point; bucket = series[i] %>
49
+ <% description = "#{tips[i]['at']} · #{label}: #{model_chart_value(bucket[field], unit)}" %>
50
+ <% description += " · #{tips[i]['note']}" if tips[i]["note"] && field == "cost" %>
51
+ <% if unit == "requests" %>
52
+ <rect class="model-bar requests" x="<%= x - slot * 0.31 %>" y="<%= y %>" width="<%= slot * 0.62 %>" height="<%= 180 - y %>" rx="3" fill="url(#<%= chart_id %>-<%= field %>)" tabindex="0" data-index="<%= i %>" aria-label="<%= description %>"><title><%= description %></title></rect>
53
+ <% else %>
54
+ <circle class="model-point <%= color %>" cx="<%= x %>" cy="<%= y %>" r="3.5" tabindex="0" data-index="<%= i %>" aria-label="<%= description %>"><title><%= description %></title></circle>
55
+ <% end %>
56
+ <% end %>
57
+ <% end %>
58
+ <% end %>
59
+ </svg>
60
+ <div class="model-tip" role="status" aria-live="polite" hidden data-model-chart-target="tip"></div>
61
+ <% unless has_values %><div class="model-chart-empty"><strong><%= empty %></strong><span><%= series.sum { |b| b["requests"].to_i }.zero? ? "New requests will appear here." : "This provider has not reported these values." %></span></div><% end %>
62
+ </div>
63
+ <div class="model-axis-x" aria-hidden="true">
64
+ <% unless series.empty? %>
65
+ <% [0, series.size / 2, series.size - 1].uniq.each do |i| %>
66
+ <span><%= Time.iso8601(series[i]["at"]).utc.strftime(step < 86_400 ? (step <= 3600 ? "%H:%M" : "%b %-d · %H:%M") : "%b %-d") %></span>
67
+ <% end %>
68
+ <% end %>
69
+ </div>
70
+ </div>
71
+ <details class="model-chart-data">
72
+ <summary>View chart data<span><%= model_interval_label(step) %> · UTC</span></summary>
73
+ <div class="model-table-scroll"><table class="grid">
74
+ <caption class="sr-only"><%= title %> — exact values</caption>
75
+ <thead><tr><th scope="col">Interval starts (UTC)</th><% fields.each do |_field, label| %><th scope="col" class="num"><%= label %> (<%= unit %>)</th><% end %><% if unit == "USD" %><th scope="col">Unknown cost</th><% end %></tr></thead>
76
+ <tbody><% series.each do |bucket| %><tr>
77
+ <th scope="row"><%= Time.iso8601(bucket["at"]).utc.strftime("%Y-%m-%d %H:%M UTC") %></th>
78
+ <% fields.each do |field, _label| %><td class="num"><%= bucket[field].nil? ? "No reported value" : model_chart_value(bucket[field], unit) %></td><% end %>
79
+ <% if unit == "USD" %><td><%= bucket["unknown_cost_requests"].to_i %> of <%= bucket["requests"] %> requests</td><% end %>
80
+ </tr><% end %></tbody>
81
+ </table></div>
82
+ </details>
@@ -0,0 +1,38 @@
1
+ <%# Master column of the Refinement miller shell — rendered by the index
2
+ (refinement.erb) and the detail (refinement_detail.erb). One agent's run
3
+ history, most recent first (@runs — see refinement_rows). Rows target the
4
+ refinement-detail turbo-frame with data-turbo-action="advance": the detail
5
+ loads beside the list while the URL moves, so refresh/deep-link render the
6
+ same screen. Local: active_id lights the current row. %>
7
+ <aside class="drill-pane drill-master">
8
+ <div class="drill-pane-head">
9
+ <div class="title"><strong>Runs <span class="count"><%= @runs.size %></span></strong></div>
10
+ </div>
11
+ <div class="drill-search">
12
+ <input type="search" class="filter-input" placeholder="Filter runs by status or id…" aria-label="Filter runs"
13
+ data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
14
+ </div>
15
+ <nav class="drill-pane-body drill-list" aria-label="refinement runs" data-controller="active-link">
16
+ <% active_id = defined?(active_id) ? active_id : nil %>
17
+ <% if @runs.empty? %>
18
+ <div class="empty">No runs yet for <span class="mono"><%= @agent %></span>.</div>
19
+ <% else %>
20
+ <% @runs.each do |run| %>
21
+ <a class="drill-item<%== ' active' if run.id == active_id %>"
22
+ href="/studio/refinement/<%= Rack::Utils.escape(run.id) %>"
23
+ data-turbo-frame="refinement-detail" data-turbo-action="advance"
24
+ data-list-filter-target="item" data-filter-text="<%= run.status %> <%= run.id %>"
25
+ data-active-link-target="item" data-action="click->active-link#select">
26
+ <div class="drill-item-name">
27
+ <span class="status <%= status_class(run.status) %>"><%= run.status %></span>
28
+ <span class="mono txt" title="<%= run.id %>"><%= short_id(run.id) %></span>
29
+ </div>
30
+ <div class="drill-item-sub">
31
+ <%= run.findings.size %> finding(s) · <%= refinement_window(run.window) %> · <%= refinement_elapsed(run) %>
32
+ </div>
33
+ </a>
34
+ <% end %>
35
+ <div class="empty" data-list-filter-target="empty" hidden>No runs match your filter.</div>
36
+ <% end %>
37
+ </nav>
38
+ </aside>
@@ -1,16 +1,20 @@
1
+ <%# The data-tool editor, rendered inside the Tools detail pane (tools.erb). %>
1
2
  <% new = @tool_name.empty? %>
2
- <header class="page-head">
3
- <div>
4
- <nav class="crumbs"><a href="/studio/tools">tools</a> / <span><%= new ? "new (data tool)" : @tool_name %></span></nav>
5
- <h1><%= new ? "New data tool" : @tool_name %><span class="sub">an HTTP call described by data — no code</span></h1>
3
+ <div class="drill-pane-head">
4
+ <div class="title">
5
+ <strong class="mono"><%= new ? "New data tool" : @tool_name %></strong>
6
+ <span class="count">an HTTP call described by data — no code</span>
6
7
  </div>
7
- </header>
8
+ </div>
9
+ <div class="drill-pane-body">
8
10
 
9
11
  <p class="muted matrix-hint">The tool makes an HTTP request when the model calls it. Use <code>{{param}}</code> in any field (url, query, headers, body) to insert arguments — https only, private destinations are blocked. Credential headers listed in <strong>secret_headers</strong> never return in plaintext: they appear as <code><%= secret_sentinel %></code> (keeping it preserves the secret).</p>
10
12
 
11
13
  <form class="card" method="post" action="<%= new ? "/studio/tools/def" : "/studio/tools/def/#{Rack::Utils.escape(@tool_name)}" %>" data-controller="dirty-guard">
12
14
  <%== csrf_tag %>
13
15
 
16
+ <fieldset class="subgroup">
17
+ <legend>Tool identity &amp; request</legend>
14
18
  <div class="grid-2">
15
19
  <label>name <span class="muted">(identifier for the model, <code>[a-z0-9_]</code>)</span>
16
20
  <% if new %>
@@ -33,13 +37,21 @@
33
37
  </label>
34
38
 
35
39
  <label>url <span class="muted">(https; use <code>{{param}}</code>)</span>
36
- <input type="text" name="url" value="<%= @form[:url] %>" placeholder="https://viacep.com.br/ws/{{cep}}/json" required>
40
+ <input type="text" name="url" value="<%= @form[:url] %>" placeholder="https://viacep.com.br/ws/{{cep}}/json"<%== " required" unless @form[:presentation] %>>
37
41
  </label>
38
42
 
43
+ </fieldset>
44
+
45
+ <fieldset class="subgroup">
46
+ <legend>Arguments from the model</legend>
39
47
  <label>parameters <span class="muted">(one per line: <code>name | type | required|optional | description</code> — type is <code>string</code>/<code>number</code>/<code>integer</code>/<code>boolean</code> or <code>array:string</code>. For a list of objects or a nested object, paste a full <strong>JSON Schema</strong> instead: any text starting with <code>{</code> is read as one.)</span>
40
48
  <textarea name="parameters" rows="6" placeholder="cep | string | required | The ZIP code to look up"><%= @form[:parameters] %></textarea>
41
49
  </label>
42
50
 
51
+ </fieldset>
52
+
53
+ <details class="newfile"<%== " open" if %i[query headers body secret_headers].any? { |key| !@form[key].to_s.empty? } %>>
54
+ <summary>Query, headers &amp; request body</summary>
43
55
  <div class="grid-2">
44
56
  <label>query <span class="muted">(one <code>key=value</code> per line)</span>
45
57
  <textarea name="query" rows="3" placeholder="term={{q}}"><%= @form[:query] %></textarea>
@@ -59,10 +71,14 @@
59
71
  </div>
60
72
  </label>
61
73
 
74
+ </details>
75
+
76
+ <fieldset class="subgroup">
77
+ <legend>Response &amp; safeguards</legend>
62
78
  <div class="grid-2">
63
79
  <label>response
64
80
  <select name="extract">
65
- <% [["body_raw", "raw body"], ["json_path", "JSON field"], ["status", "status only"]].each do |v, lbl| %>
81
+ <% [["body_raw", "raw body"], ["json_path", "JSON field"], ["status", "status only"], ["evidence_envelope", "evidence envelope"]].each do |v, lbl| %>
66
82
  <option value="<%= v %>"<%== " selected" if @form[:extract] == v %>><%= lbl %></option>
67
83
  <% end %>
68
84
  </select>
@@ -72,13 +88,19 @@
72
88
  </label>
73
89
  </div>
74
90
 
91
+ <label>requires_evidence <span class="muted">(parameter names, comma-separated; each ID must first be returned by a tool in this conversation)</span>
92
+ <input type="text" name="requires_evidence" value="<%= @form[:requires_evidence] %>" placeholder="product_id">
93
+ </label>
94
+
75
95
  <label>timeout <span class="muted">(seconds, optional)</span>
76
96
  <input type="number" name="timeout" value="<%= @form[:timeout] %>" min="1" placeholder="30">
77
97
  </label>
78
98
 
99
+ </fieldset>
100
+
79
101
  <div class="actions">
102
+ <span class="muted">Saving updates this tool for every agent that uses it.</span>
80
103
  <button type="submit" class="primary" data-turbo-submits-with="<%= new ? "Creating…" : "Saving…" %>"><%= new ? "Create tool" : "Save tool" %></button>
81
- <a class="btn ghost" href="/studio/tools">← back</a>
82
104
  </div>
83
105
  </form>
84
106
 
@@ -92,8 +114,8 @@
92
114
  </form>
93
115
 
94
116
  <% if @versions.any? %>
95
- <div class="card">
96
- <h2 class="eyebrow">versions</h2>
117
+ <details class="card newfile">
118
+ <summary>Previous versions <span class="count"><%= @versions.size %></span></summary>
97
119
  <% @versions.each_with_index do |v, i| %>
98
120
  <form class="promptfile-head" method="post" action="/studio/tools/def/<%= Rack::Utils.escape(@tool_name) %>/restore">
99
121
  <%== csrf_tag %>
@@ -102,6 +124,11 @@
102
124
  <button type="submit" class="ghost">Restore</button>
103
125
  </form>
104
126
  <% end %>
105
- </div>
127
+ </details>
106
128
  <% end %>
107
129
  <% end %>
130
+
131
+ <% unless new %>
132
+ <%== render("_tool_users", locals: { name: @tool_name }) %>
133
+ <% end %>
134
+ </div>
@@ -0,0 +1,17 @@
1
+ <%# Which agents can call tool `name` (@tool_reach: own list, kits, groups or "all"). %>
2
+ <% users = @tool_reach[name] %>
3
+ <div class="drill-section">
4
+ <div class="drill-section-head">
5
+ <h3>Used by <span class="count"><%= users.size %></span></h3>
6
+ <span class="muted">Change an agent's access in its Tools tab.</span>
7
+ </div>
8
+ <% if users.empty? %>
9
+ <p class="muted">No agent can call this tool yet.</p>
10
+ <% else %>
11
+ <p>
12
+ <% users.each do |id| %>
13
+ <a class="pill plain" href="/studio/agents/<%= Rack::Utils.escape(id) %>?tab=tools" data-turbo-frame="_top"><%= id %></a>
14
+ <% end %>
15
+ </p>
16
+ <% end %>
17
+ </div>
@@ -0,0 +1,53 @@
1
+ <%# "Why did it say that?" under a reply: what the turn read (context tokens
2
+ by category), the skills it injected and why, what it called, the checks on
3
+ the customer's message. Locals: w = a why_panels entry, sid, index (the
4
+ reply's message index). Bars are SVG geometry — CSP-safe, no style="". %>
5
+ <% ctx = w[:context] %>
6
+ <details class="toolcard why-card">
7
+ <summary><strong>why this reply</strong></summary>
8
+ <div class="why">
9
+ <h3>What it read</h3>
10
+ <% if ctx %>
11
+ <% scale = [ctx["cap"].to_i, ctx["used"].to_i, 1].max %>
12
+ <p class="muted"><%= ctx["used"] %> / <%= ctx["cap"] %> tokens<% if (hit = ctx.dig("cache", "hit_pct")) %> · <%= hit %>% cached<% end %></p>
13
+ <dl class="why-bars">
14
+ <% (ctx["categories"] || {}).sort_by { |_k, v| -(v["tokens"] || 0) }.each do |name, cat| %>
15
+ <dt><%= name %></dt>
16
+ <dd><svg viewBox="0 0 100 4" preserveAspectRatio="none" aria-hidden="true"><rect class="track" width="100" height="4"/><rect width="<%= (cat["tokens"].to_f / scale * 100).round(2).clamp(0, 100) %>" height="4"/></svg><span><%= cat["tokens"] %></span></dd>
17
+ <% end %>
18
+ </dl>
19
+ <% skills = Array((ctx["categories"] || {}).dig("skilltrigger", "labels")).uniq %>
20
+ <% unless skills.empty? %>
21
+ <h3>Skills injected</h3>
22
+ <ul><% skills.each do |l| %><li class="mono"><% name = l.is_a?(Hash) ? l["name"].to_s : l.to_s %><a href="/studio/skills/<%= Rack::Utils.escape(name) %>" data-turbo-frame="_top"><%= skill_label(l) %></a></li><% end %></ul>
23
+ <% end %>
24
+ <% else %>
25
+ <p class="muted">No context trace for this turn.</p>
26
+ <% end %>
27
+
28
+ <h3>What it called</h3>
29
+ <% if w[:tools].any? %>
30
+ <ul>
31
+ <% w[:tools].each do |t| %>
32
+ <li><span class="status <%= t["ok"] && !t["gate"] ? "ok" : "err" %>"><%= t["gate"] ? "blocked: #{t["gate"]}" : (t["ok"] ? "ok" : "error") %></span> <span class="mono"><%= t["tool"] %></span> <span class="muted"><%= t["ms"] %>ms</span></li>
33
+ <% end %>
34
+ </ul>
35
+ <% elsif w[:calls].any? %>
36
+ <p class="mono"><%= w[:calls].join(", ") %> <span class="muted">· no trace recorded</span></p>
37
+ <% else %>
38
+ <p class="muted">No tool calls.</p>
39
+ <% end %>
40
+
41
+ <h3>Checks</h3>
42
+ <% if w[:flags].any? %>
43
+ <ul><% w[:flags].each do |f| %><li><span class="status err"><%= f["action"] == "refuse" ? "blocked" : "flagged" %></span> <%= f["category"] || "?" %></li><% end %></ul>
44
+ <% else %>
45
+ <p class="muted">No guardrail flags on the customer's message.</p>
46
+ <% end %>
47
+
48
+ <div class="actions">
49
+ <a class="btn btn-sm" href="/studio/playground?session_id=<%= Rack::Utils.escape(sid) %>" data-turbo-frame="_top">Replay in Playground</a>
50
+ <a class="btn btn-sm" href="/studio/evals?<%= Rack::Utils.build_query("session" => sid, "reply" => index) %>" data-turbo-frame="_top">Save as eval case</a>
51
+ </div>
52
+ </div>
53
+ </details>
@@ -9,12 +9,12 @@
9
9
  <% unless frame_only %>
10
10
  <header class="page-head">
11
11
  <div>
12
- <nav class="crumbs"><a href="/studio/agents">agents</a> / <span><%= @agent.id %></span></nav>
12
+ <nav class="crumbs"><a href="/studio/agents">agents</a></nav>
13
13
  <h1><span class="avatar sm <%= avatar_class(@agent.id) %>"><%= @agent.id[0, 2].upcase %></span><%= @agent.id %>
14
14
  <span class="sub"><%= @agent.model %><%== " · #{h(@agent.provider)}" if @agent.provider %></span>
15
15
  </h1>
16
16
  </div>
17
- <a class="btn primary" href="/studio/playground?agent=<%= Rack::Utils.escape(@agent.id) %>">Open playground →</a>
17
+ <a class="btn primary" href="/studio/playground?agent=<%= Rack::Utils.escape(@agent.id) %>">Open playground</a>
18
18
  </header>
19
19
 
20
20
  <div class="drill" data-controller="list-filter">
@@ -32,6 +32,9 @@
32
32
  <div class="title">
33
33
  <strong class="mono"><%= @agent.id %></strong>
34
34
  <span class="count"><%= @agent.model %><%== " · #{h(@agent.provider)}" if @agent.provider %></span>
35
+ <% Array(@agent.kits).each do |k| %>
36
+ <a class="pill info" href="/studio/kits/<%= Rack::Utils.escape(k) %>" data-turbo-frame="_top" title="kit">kit: <%= k %></a>
37
+ <% end %>
35
38
  </div>
36
39
  <a class="btn btn-sm" href="/studio/playground?agent=<%= Rack::Utils.escape(@agent.id) %>" data-turbo-frame="_top">Test in playground</a>
37
40
  </div>
@@ -43,24 +46,28 @@
43
46
  inside turbo-frame#agent-detail), dropping the #fragment and
44
47
  re-fetching the frame instead of letting the browser set
45
48
  location.hash — which is the only thing tabs_controller reads. %>
46
- <a href="#config" data-tabs-target="tab" data-turbo="false">config</a>
47
- <a href="#prompts" data-tabs-target="tab" data-turbo="false">prompts</a>
48
- <a href="#skills" data-tabs-target="tab" data-turbo="false">skills</a>
49
- <a href="#memory" data-tabs-target="tab" data-turbo="false">memory</a>
50
- <a href="#outcomes" data-tabs-target="tab" data-turbo="false">outcomes</a>
51
- <a href="#loops" data-tabs-target="tab" data-turbo="false">loops</a>
52
- <a href="#cache" data-tabs-target="tab" data-turbo="false">cache</a>
53
- <a href="#history" data-tabs-target="tab" data-turbo="false">history</a>
49
+ <a href="#config" data-tabs-target="tab" data-turbo="false">Configuration</a>
50
+ <a href="#prompts" data-tabs-target="tab" data-turbo="false">Prompts</a>
51
+ <a href="#skills" data-tabs-target="tab" data-turbo="false">Skills</a>
52
+ <a href="#tools" data-tabs-target="tab" data-turbo="false">Tools</a>
53
+ <a href="#memory" data-tabs-target="tab" data-turbo="false">Memory</a>
54
+ <a href="#outcomes" data-tabs-target="tab" data-turbo="false">Outcomes</a>
55
+ <a href="#loops" data-tabs-target="tab" data-turbo="false">Automatic loops</a>
56
+ <a href="#cache" data-tabs-target="tab" data-turbo="false">Cache</a>
57
+ <a href="#history" data-tabs-target="tab" data-turbo="false">History</a>
58
+ <a href="#performance" data-tabs-target="tab" data-turbo="false">Performance</a>
54
59
  </nav>
55
60
 
56
61
  <%== render("_agent_tab_config") %>
57
62
  <%== render("_agent_tab_prompts") %>
58
63
  <%== render("_agent_tab_skills") %>
64
+ <%== render("_agent_tab_tools") %>
59
65
  <%== render("_agent_tab_memory") %>
60
66
  <%== render("_agent_tab_loops") %>
61
67
  <%== render("_agent_tab_outcomes") %>
62
68
  <%== render("_agent_tab_cache") %>
63
69
  <%== render("_agent_tab_history") %>
70
+ <%== render("_agent_tab_performance") %>
64
71
  </div>
65
72
  </div>
66
73
  </turbo-frame>
@@ -4,25 +4,40 @@
4
4
  create form; both submit to the top window so the fresh master list
5
5
  renders with the new agent in it. %>
6
6
  <header class="page-head">
7
- <h1>Agents<span class="sub"><%= @agents.size %> configured · one agent each</span></h1>
8
- <a class="btn primary" href="/studio/playground">Open playground →</a>
7
+ <h1>Agents<span class="sub"><%= @agents.size %> configured · models, prompts and capabilities</span></h1>
8
+ <a class="btn primary" href="/studio/playground">Open playground</a>
9
9
  </header>
10
10
 
11
11
  <div class="drill" data-controller="list-filter">
12
- <%== render("_agents_master", locals: { active_id: nil, show_outcomes: true }) %>
12
+ <%== render("_agents_master", locals: { active_id: nil }) %>
13
13
 
14
14
  <turbo-frame id="agent-detail" class="drill-pane drill-detail">
15
15
  <div class="drill-pane-head">
16
16
  <div class="title">
17
- <strong>Pick an agent</strong>
18
- <span class="count">select on the left, or start a new one below</span>
17
+ <strong>Configure an agent</strong>
18
+ <span class="count">Select an agent or create one below</span>
19
19
  </div>
20
20
  </div>
21
21
  <div class="drill-pane-body">
22
- <% if @templates&.any? %>
22
+ <% open_agents = @agents.select { |a| agent_open?(a) } %>
23
+ <% if open_agents.any? %>
23
24
  <div class="section">
24
25
  <div class="section-head">
25
- <h2>From template</h2>
26
+ <h2><%= open_agents.size %> <%= open_agents.size == 1 ? "agent can" : "agents can" %> call every tool or skill</h2>
27
+ <p class="muted">Kits add nothing to these agents, and any tool or skill added later reaches them without anyone deciding. Open each one and turn off <strong>all tools</strong> or <strong>all skills</strong>.</p>
28
+ </div>
29
+ <p>
30
+ <% open_agents.each do |a| %>
31
+ <a class="pill warn" href="/studio/agents/<%= Rack::Utils.escape(a.id) %>?tab=<%= a.skills.nil? && !(a.tools_allow.nil? && a.tools_allow_groups.nil?) ? "skills" : "tools" %>" data-turbo-frame="_top"><%= a.id %></a>
32
+ <% end %>
33
+ </p>
34
+ </div>
35
+ <% end %>
36
+ <% if @templates&.any? %>
37
+ <details class="section newfile"<%== " open" if @agents.empty? %>>
38
+ <summary>Start from a template <span class="count"><%= @templates.size %></span></summary>
39
+ <div class="section-head">
40
+ <h2 class="sr-only">From template</h2>
26
41
  <p class="muted">Example agents shipped in the gem — pick one to see a working pattern, then edit it like any agent.</p>
27
42
  </div>
28
43
  <div class="template-grid">
@@ -47,29 +62,26 @@
47
62
  </article>
48
63
  <% end %>
49
64
  </div>
50
- </div>
65
+ </details>
51
66
  <% end %>
52
67
 
53
68
  <div class="section">
54
69
  <div class="section-head">
55
70
  <h2>New agent</h2>
56
- <p class="muted">id + model — give it an identity on its detail page.</p>
71
+ <p class="muted">Choose an ID and model. It starts with no tools or skills: add a kit or pick them after creating it.</p>
57
72
  </div>
58
- <form method="post" action="/studio/agents" data-turbo-frame="_top">
73
+ <form method="post" action="/studio/agents" data-turbo-frame="_top" data-controller="dirty-guard">
59
74
  <%== csrf_tag %>
60
75
  <div class="grid-2">
61
76
  <label>id <span class="muted">(unique slug, e.g. demo)</span>
62
77
  <input type="text" name="id" placeholder="demo" required>
63
78
  </label>
64
- <label>model
65
- <input type="text" name="model" placeholder="deepseek-v4-flash" required>
79
+ <label>model <span class="muted">(blank = platform default)</span>
80
+ <%== model_select("model_ref", nil, "platform default") %>
66
81
  </label>
67
82
  </div>
68
- <label>provider <span class="muted">(optional)</span>
69
- <input type="text" name="provider" placeholder="deepseek">
70
- </label>
71
83
  <label class="check">
72
- <input type="checkbox" name="memory" value="1" checked>
84
+ <input type="checkbox" name="memory" value="1">
73
85
  cross-session memory (the agent remembers across conversations)
74
86
  </label>
75
87
  <div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Creating…">Create agent</button></div>
@@ -1,11 +1,11 @@
1
1
  <header class="page-head">
2
2
  <div>
3
- <nav class="crumbs"><span>approvals</span></nav>
3
+ <%== crumbs_for("Approvals") %>
4
4
  <h1>Approvals<span class="sub"><%= @approvals.size %> pending</span></h1>
5
5
  </div>
6
6
  <div class="head-actions">
7
7
  <%== agent_filter_form("/studio/approvals", @agent) %>
8
- <a class="btn" href="/studio/tasks">Tasks →</a>
8
+ <a class="btn" href="/studio/tasks">Open tasks</a>
9
9
  </div>
10
10
  </header>
11
11
 
@@ -14,31 +14,36 @@
14
14
  <% else %>
15
15
  <% @approvals.each do |a| %>
16
16
  <% pa = a[:pending]; task = a[:task] %>
17
- <div class="card">
18
- <div class="skill-name mono">
17
+ <article class="card approval-review">
18
+ <div class="review-heading"><div><h2 class="mono">
19
19
  <%= pa.tool %>
20
- <% if task %><span class="pill <%= status_class(task.status) %>"><%= task.status %></span><% end %>
20
+ </h2></div>
21
+ <% if task %><span class="status <%= status_class(task.status) %>"><%= task.status %></span><% end %>
21
22
  </div>
22
23
  <div class="skill-desc muted">
23
24
  <a class="mono" href="<%= task_path(pa.task_id) %>">task <%= pa.task_id[0, 12] %></a>
24
25
  · turn <%= pa.turn %> · requested <%= pa.requested_at %>
25
26
  </div>
26
- <pre><%= message_content(pa.args) %></pre>
27
+ <div class="review-columns">
28
+ <section><h3>Requested action</h3><pre class="review-source"><%= message_content(pa.args) %></pre></section>
29
+ <section class="review-decision"><h3>Allow this action?</h3><p class="muted">Approval resumes the task and allows this tool call. Rejection resumes the turn with the tool denied.</p>
27
30
  <div class="actions">
28
31
  <form method="post" action="/studio/approvals/<%= Rack::Utils.escape(pa.id) %>">
29
32
  <%== csrf_tag %>
30
33
  <input type="hidden" name="decision" value="approved">
31
- <input type="hidden" name="back" value="/studio/approvals">
32
- <button type="submit" class="primary">Approve</button>
34
+ <input type="hidden" name="back" value="<%= @agent ? "/studio/approvals?agent=#{Rack::Utils.escape(@agent)}" : "/studio/approvals" %>">
35
+ <button type="submit" class="primary" data-turbo-submits-with="Approving…">Approve</button>
33
36
  </form>
34
37
  <form method="post" action="/studio/approvals/<%= Rack::Utils.escape(pa.id) %>"
35
38
  data-turbo-confirm="Reject this action? The turn resumes as if the tool was denied.">
36
39
  <%== csrf_tag %>
37
40
  <input type="hidden" name="decision" value="rejected">
38
- <input type="hidden" name="back" value="/studio/approvals">
39
- <button type="submit" class="danger">Reject</button>
41
+ <input type="hidden" name="back" value="<%= @agent ? "/studio/approvals?agent=#{Rack::Utils.escape(@agent)}" : "/studio/approvals" %>">
42
+ <button type="submit" class="danger" data-turbo-submits-with="Rejecting…">Reject</button>
40
43
  </form>
41
44
  </div>
42
- </div>
45
+ </section>
46
+ </div>
47
+ </article>
43
48
  <% end %>
44
49
  <% end %>