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. running reads 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

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)
  meta = child.
  return nil unless meta.is_a?(Hash)
  return "handoff" if meta.key?("handoff_from")

  "delegation" if meta.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

Returns:

  • (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