Module: Silas::Inbox::TraceHelper
- Defined in:
- app/helpers/silas/inbox/trace_helper.rb
Constant Summary collapse
- STATUS_CLASS =
The seven run states, in aspect (direction "Signals"): running is the only aspect that pulses, in_doubt gets its own violet (it is neither waiting-by-design nor failed), and canceled is a lamp going OUT — dashed quiet, never red. Failed keeps the only red.
{ "queued" => "pill-grey", "running" => "pill-blue pill-pulse", "waiting" => "pill-amber", "in_doubt" => "pill-violet", "completed" => "pill-green", "failed" => "pill-red", "canceled" => "pill-quiet", # tool-invocation statuses map onto the same seven "pending" => "pill-grey", "started" => "pill-blue", "declined" => "pill-red", "approved" => "pill-green", "answered" => "pill-green", "required" => "pill-amber", "expired" => "pill-quiet" }.freeze
- UI_LABEL =
UI-only relabels — the database strings and the JSON API are untouched (an operator who reads "held" here and greps the API will find
waiting; docs name both). Safety-system vocabulary: a turn is held at the signal until a person clears it.runningreads WORKING so the pill and the rail's Working group say the same word about the same turn; the disclosure on every tool row prints the raw enum. { "waiting" => "held", "running" => "working", "completed" => "clear" }.freeze
- INVOCATION_STATE =
A verdict outranks the status it wrote:
decline!sets status=failed, but a person refusing is not a crash and must not read like one. { "pending" => "working", "started" => "working", "completed" => "clear", "failed" => "failed", "in_doubt" => "in_doubt" }.freeze
- STATE_ASPECT =
{ "held" => "amber", "working" => "blue", "clear" => "green", "failed" => "red", "declined" => "red", "expired" => "quiet", "in_doubt" => "violet" }.freeze
- SETTLED_QUIETLY =
The states are settled quietly ONLY here; every other state either needs a person or lost one.
%w[clear working].freeze
- MONEY_KEYS =
%w[amount total price subtotal].freeze
- CURRENCY_KEYS =
%w[currency currency_code].freeze
- LINEAGE_FROM =
---- lineage -----------------------------------------------------
A handoff or a delegation starts a SECOND session. Nothing on the child points back at the call that made it — the only link is the invocation result — so the feed recovers the pair from there, and the child's metadata says which of the two it was.
{ "handoff" => "handed over by", "delegation" => "delegated by" }.freeze
- LINEAGE_TO =
{ "handoff" => "handed to", "delegation" => "delegated to" }.freeze
Instance Method Summary collapse
-
#arg_phrase(key, value) ⇒ Object
order_id: 4821reads as "order #4821". - #cost_label(cost) ⇒ Object
- #declined_phrase(invocation) ⇒ Object
-
#invocation_arg_phrases(invocation, limit: 3) ⇒ Object
The middle of the sentence: enough of the call to recognise the job without opening anything.
- #invocation_child_session(invocation) ⇒ Object
- #invocation_outcome(invocation) ⇒ Object
-
#invocation_result_phrase(invocation) ⇒ Object
What came back.
- #invocation_state(invocation) ⇒ Object
- #invocation_state_class(invocation) ⇒ Object
-
#invocation_weight(invocation) ⇒ Object
Salience.
- #lineage_from_phrase(child) ⇒ Object
- #lineage_relation(child) ⇒ Object
- #lineage_to_phrase(child) ⇒ Object
-
#money_phrase(args) ⇒ Object
"GBP 64.00" is what an operator scans a refund line for; as two separate phrases the currency and the number sit apart and neither reads as money.
- #pretty_args(hash) ⇒ Object
- #result_or_failure_phrase(invocation, state) ⇒ Object
- #scalar_arg?(value) ⇒ Boolean
-
#session_state_turn(session) ⇒ Object
The turn a session is judged by: the one in flight, else the last one to settle.
-
#silas_engine_path(helper, *args) ⇒ Object
Engine paths that resolve in EVERY render context.
-
#silas_relative_time(time) ⇒ Object
Prefixed: this helper is registered host-wide (broadcast renders need it), and "relative_time" is exactly the name a host app would define.
- #status_label(status) ⇒ Object
- #status_pill(status) ⇒ Object
-
#step_structured(step) ⇒ Object
The final_answer payload, when the agent declared a schema.
- #step_text(step) ⇒ Object
-
#stranded_child_sessions(session, turns) ⇒ Object
Children the feed has no place for.
-
#turn_effects_summary(turn) ⇒ Object
A settled turn's one-line audit: how much ran, and how much of it wrote.
-
#turn_progress(turn) ⇒ Object
Where the turn stands, in the words the pill has no room for.
- #verdict_phrase(invocation) ⇒ Object
Instance Method Details
#arg_phrase(key, value) ⇒ Object
order_id: 4821 reads as "order #4821". A bare flag reads as itself:
"dry run" beats "dry run true", and "no dry run" beats "dry run false".
153 154 155 156 157 158 159 160 161 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 153 def arg_phrase(key, value) return "#{key.delete_suffix("_id").tr("_", " ")} ##{value}" if key.end_with?("_id") label = key.tr("_", " ") return label if value == true return "no #{label}" if value == false "#{label} #{value.to_s.truncate(48)}" end |
#cost_label(cost) ⇒ Object
47 48 49 50 51 52 53 54 55 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 47 def cost_label(cost) tokens = "#{number_with_delimiter(cost[:input_tokens])} in / #{number_with_delimiter(cost[:output_tokens])} out" money = Silas::Inbox::Cost.format(cost[:microcents]) if cost[:unpriced] || money.nil? "#{tokens} · cost unavailable" else "#{tokens} · #{money}" end end |
#declined_phrase(invocation) ⇒ Object
265 266 267 268 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 265 def declined_phrase(invocation) who = invocation.approved_by.present? ? "declined by #{invocation.approved_by}" : "declined" invocation.decline_reason.present? ? "#{who} — “#{invocation.decline_reason}”" : who end |
#invocation_arg_phrases(invocation, limit: 3) ⇒ Object
The middle of the sentence: enough of the call to recognise the job without opening anything. Non-scalars are left out — a nested hash flattened onto one line is noise — and wait in the disclosure.
126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 126 def invocation_arg_phrases(invocation, limit: 3) args = invocation.arguments return [] unless args.is_a?(Hash) && args.any? merged, phrases = money_phrase(args) args.each do |key, value| break if phrases.size >= limit next if merged.include?(key.to_s) || !scalar_arg?(value) phrases << arg_phrase(key.to_s, value) end phrases end |
#invocation_child_session(invocation) ⇒ Object
220 221 222 223 224 225 226 227 228 229 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 220 def invocation_child_session(invocation) result = invocation.result return nil unless result.is_a?(Hash) && result["session_id"].present? child = Silas::Session.find_by(id: result["session_id"]) # Any tool may return a key called `session_id` meaning something of its # own. Only a session whose parent IS this call's session was started # by this call. child if child && child.parent_session_id == invocation.turn.session_id end |
#invocation_outcome(invocation) ⇒ Object
111 112 113 114 115 116 117 118 119 120 121 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 111 def invocation_outcome(invocation) state = invocation_state(invocation) case state when "held" then invocation.question? ? "held for an answer" : "held for approval" when "working" then "working" when "in_doubt" then "in doubt — nobody knows whether it ran" when "declined" then declined_phrase(invocation) when "expired" then invocation.question? ? "expired — nobody answered" : "expired — nobody cleared it" else [ verdict_phrase(invocation), result_or_failure_phrase(invocation, state) ].compact.join(" · ") end end |
#invocation_result_phrase(invocation) ⇒ Object
What came back. A tool that returned nothing SAYS so — the absence is a fact about the run, not a gap in the sentence.
165 166 167 168 169 170 171 172 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 165 def invocation_result_phrase(invocation) result = invocation.result return "returned nothing" if result.nil? || result == {} || result == "" return result.to_s.truncate(48) unless result.is_a?(Hash) key, value = result.find { |_k, v| scalar_arg?(v) } key ? arg_phrase(key.to_s, value) : "#{result.size} #{"field".pluralize(result.size)} returned" end |
#invocation_state(invocation) ⇒ Object
87 88 89 90 91 92 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 87 def invocation_state(invocation) return "held" if invocation.awaiting_approval? return invocation.approval_state if %w[declined expired].include?(invocation.approval_state) INVOCATION_STATE.fetch(invocation.status, invocation.status) end |
#invocation_state_class(invocation) ⇒ Object
94 95 96 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 94 def invocation_state_class(invocation) "deed-#{STATE_ASPECT.fetch(invocation_state(invocation), 'quiet')}" end |
#invocation_weight(invocation) ⇒ Object
Salience. A read that worked is furniture; a tool that moved money or
sent a message is not, and neither is anything a person still owes a
verdict on. A FAILED read is loud too — having no effect doesn't make
a failure quiet. idempotent is the mode a tool declares when it only
reads; transactional is the one whose write and ledger row commit
together, and it gets the heaviest rule on the page.
104 105 106 107 108 109 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 104 def invocation_weight(invocation) return "deed-exact" if invocation.effect_mode == "transactional" return "deed-loud" unless SETTLED_QUIETLY.include?(invocation_state(invocation)) invocation.effect_mode == "idempotent" ? "deed-quiet" : "deed-loud" end |
#lineage_from_phrase(child) ⇒ Object
217 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 217 def lineage_from_phrase(child) = LINEAGE_FROM.fetch(lineage_relation(child), "started by") |
#lineage_relation(child) ⇒ Object
209 210 211 212 213 214 215 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 209 def lineage_relation(child) = child. return nil unless .is_a?(Hash) return "handoff" if .key?("handoff_from") "delegation" if .key?("delegated_from") end |
#lineage_to_phrase(child) ⇒ Object
218 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 218 def lineage_to_phrase(child) = LINEAGE_TO.fetch(lineage_relation(child), "started") |
#money_phrase(args) ⇒ Object
"GBP 64.00" is what an operator scans a refund line for; as two separate phrases the currency and the number sit apart and neither reads as money. Returns [keys consumed, phrases].
143 144 145 146 147 148 149 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 143 def money_phrase(args) currency = args.find { |k, v| CURRENCY_KEYS.include?(k.to_s) && v.to_s.match?(/\A[A-Za-z]{3}\z/) } amount = args.find { |k, v| MONEY_KEYS.include?(k.to_s) && v.is_a?(Numeric) } return [ [], [] ] unless currency && amount [ [ currency[0].to_s, amount[0].to_s ], [ format("%s %.2f", currency[1].to_s.upcase, amount[1]) ] ] end |
#pretty_args(hash) ⇒ Object
43 44 45 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 43 def pretty_args(hash) JSON.pretty_generate(hash || {}) end |
#result_or_failure_phrase(invocation, state) ⇒ Object
270 271 272 273 274 275 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 270 def result_or_failure_phrase(invocation, state) return "failed — #{invocation.error.to_s.lines.first.to_s.strip.truncate(72).presence || "no reason recorded"}" if state == "failed" return nil if invocation.approval_state == "answered" # the answer IS the result invocation_result_phrase(invocation) end |
#scalar_arg?(value) ⇒ Boolean
248 249 250 251 252 253 254 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 248 def scalar_arg?(value) case value when String then value.present? when Numeric, true, false then true else false end end |
#session_state_turn(session) ⇒ Object
The turn a session is judged by: the one in flight, else the last one to settle. Reads off the loaded association — the index depends on that.
244 245 246 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 244 def session_state_turn(session) session.turns.detect(&:active?) || session.turns.last end |
#silas_engine_path(helper, *args) ⇒ Object
Engine paths that resolve in EVERY render context. The mounted proxy
(silas.) leans on the rendering scope's url_options, which Turbo's
bare broadcast renderer doesn't have — so broadcast-rendered partials
build paths from the engine's own route set + the discovered mount.
289 290 291 292 293 294 295 296 297 298 299 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 289 def silas_engine_path(helper, *args) helpers = Silas::Engine.routes.url_helpers # Rails 8.1's lazy route set: in a worker's FIRST broadcast render the # engine's helper module can predate its route draw, and the lazy # method_missing only retries when the app routes were *just* loaded — # otherwise it raises NoMethodError and the Turbo job dies silently # (observed: the first turn broadcast of a fresh worker). Force the # draw once on miss; every later call takes the fast path. Rails.application.reload_routes! unless helpers.respond_to?(helper) helpers.public_send(helper, *args, script_name: Silas::Inbox.mount_path) end |
#silas_relative_time(time) ⇒ Object
Prefixed: this helper is registered host-wide (broadcast renders need it), and "relative_time" is exactly the name a host app would define.
279 280 281 282 283 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 279 def silas_relative_time(time) return "" unless time "#{time_ago_in_words(time)} ago" end |
#status_label(status) ⇒ Object
26 27 28 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 26 def status_label(status) UI_LABEL[status.to_s] || status.to_s.tr("_", " ") end |
#status_pill(status) ⇒ Object
30 31 32 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 30 def status_pill(status) tag.span(status_label(status), class: "pill #{STATUS_CLASS[status.to_s] || 'pill-grey'}") end |
#step_structured(step) ⇒ Object
The final_answer payload, when the agent declared a schema.
39 40 41 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 39 def step_structured(step) Array(step.response_blocks).reverse.find { |b| b["type"] == "structured" }&.dig("data") end |
#step_text(step) ⇒ Object
34 35 36 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 34 def step_text(step) Array(step.response_blocks).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join.presence end |
#stranded_child_sessions(session, turns) ⇒ Object
Children the feed has no place for. An at_most_once handoff that crashes after Session.create! records no result, so the call that made the child names nothing — without this the child is visible only from the index, which is the orphan the lineage work exists to end.
235 236 237 238 239 240 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 235 def stranded_child_sessions(session, turns) placed = turns.flat_map(&:steps).flat_map(&:tool_invocations).filter_map do |i| i.result["session_id"].to_i if i.result.is_a?(Hash) && i.result["session_id"].present? end session.child_sessions.reject { |child| placed.include?(child.id) } end |
#turn_effects_summary(turn) ⇒ Object
A settled turn's one-line audit: how much ran, and how much of it wrote. Reads off the loaded steps so the session page adds no queries.
191 192 193 194 195 196 197 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 191 def turn_effects_summary(turn) invocations = turn.steps.flat_map(&:tool_invocations) return "no tools ran — answered directly" if invocations.empty? wrote = invocations.count { |i| i.effect_mode != "idempotent" && i.status == "completed" } "#{pluralize(invocations.size, "tool")} · #{wrote.zero? ? "nothing written" : "#{wrote} wrote"}" end |
#turn_progress(turn) ⇒ Object
Where the turn stands, in the words the pill has no room for. Never
says "waiting": the enum is waiting, the operator's word is HELD.
176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 176 def turn_progress(turn) case turn.status when "queued" then "queued for a worker" when "running" then "working now" when "waiting" then "held — a person is needed" when "in_doubt" then "in doubt — a tool crashed mid-call" when "canceled" then "stopped before it finished" when "failed" then turn.failure_reason.present? ? "failed — #{turn.failure_reason}" : "failed" when "completed" then turn.finished_at ? "answered #{silas_relative_time(turn.finished_at)}" : "answered" else status_label(turn.status) end end |
#verdict_phrase(invocation) ⇒ Object
256 257 258 259 260 261 262 263 |
# File 'app/helpers/silas/inbox/trace_helper.rb', line 256 def verdict_phrase(invocation) case invocation.approval_state when "approved" invocation.approved_by.present? ? "approved by #{invocation.approved_by}" : "auto-approved by policy" when "answered" invocation.approved_by.present? ? "answered by #{invocation.approved_by}" : "answered" end end |