Module: OllamaAgent::GemmaThoughtContentParser

Defined in:
lib/ollama_agent/gemma_thought_content_parser.rb

Overview

Incrementally strips Gemma-style reasoning channels from streamed message.content when Ollama does not populate message.thinking (common for Gemma 4 cloud vs Qwen/DeepSeek). merge_into_message_data! also runs ChatStreamThinkingFormat.normalize_message_thinking! so Hash/Array thinking payloads from the API are coerced to a String before display (README: Reasoning / thinking output).

Supported openings (earliest match wins):

  • <|channel>thought optionally followed by a single newline before reasoning text

  • <redacted_thinking>

Closures pair with the opening:

  • <channel|> (Gemma / Ollama template style)

  • </redacted_thinking>

rubocop:disable Metrics/ModuleLength – single streaming scanner; splitting would obscure state machine

Constant Summary collapse

STATE_KEY =
"__ollama_agent_gemma_thought_parse"
OPEN_SPECS =
[
  { key: "channel", open: "<|channel>thought", close: "<channel|>" },
  { key: "redacted", open: "<redacted_thinking>", close: "</redacted_thinking>" }
].freeze

Class Method Summary collapse

Class Method Details

.attach_state!(carry, state) ⇒ Object



102
103
104
105
106
# File 'lib/ollama_agent/gemma_thought_content_parser.rb', line 102

def attach_state!(carry, state)
  return unless carry.is_a?(Hash)

  carry[STATE_KEY] = copy_state(state)
end

.extract_from_complete_content(content) ⇒ Array(String, String)

One-shot parse of a full assistant body (non-streaming chat / TUI).

Returns:

  • (Array(String, String))

    [thinking_text, visible_content]; thinking_text is nil when no markers



110
111
112
113
114
115
116
117
118
119
# File 'lib/ollama_agent/gemma_thought_content_parser.rb', line 110

def extract_from_complete_content(content)
  c = content.to_s
  return [nil, c] if c.empty?

  new_content, _state, deltas = process_chunk(initial_state, c)
  thinking = deltas.join
  return [nil, c] if thinking.strip.empty?

  [thinking, new_content]
end

.extract_state(carry) ⇒ Object

rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity



93
94
95
96
97
98
99
100
# File 'lib/ollama_agent/gemma_thought_content_parser.rb', line 93

def extract_state(carry)
  return initial_state unless carry.is_a?(Hash)

  raw = carry[STATE_KEY]
  return initial_state if raw.nil? || !raw.is_a?(Hash)

  copy_state(raw)
end

.initial_stateObject



29
30
31
32
33
34
35
# File 'lib/ollama_agent/gemma_thought_content_parser.rb', line 29

def initial_state
  {
    "phase" => "content",
    "pend" => +"",
    "open_key" => nil
  }
end

.merge_into_message_data!(message) ⇒ Object

Mutates message backing hash when the API omits thinking but embeds Gemma channels in content.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/ollama_agent/gemma_thought_content_parser.rb', line 122

def merge_into_message_data!(message)
  data = message_data_hash(message)
  return unless data

  ChatStreamThinkingFormat.normalize_message_thinking!(data)
  return if native_thinking_present?(data)

  raw_content = (data["content"] || data[:content]).to_s
  return if raw_content.empty?

  thinking_text, visible = extract_from_complete_content(raw_content)
  return if thinking_text.nil? || thinking_text.strip.empty?

  data["thinking"] = thinking_text
  data["content"] = visible
end

.process_chunk(state, chunk) ⇒ Array(String, Hash, Array<String>)

rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity

Returns:

  • (Array(String, Hash, Array<String>))

    content_for_api, new_state, thinking_deltas



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/ollama_agent/gemma_thought_content_parser.rb', line 39

def process_chunk(state, chunk)
  state = copy_state(state)
  return [nil, state, []] if chunk.nil?

  deltas = []
  content_out = +""
  work = state["pend"] + chunk
  state["pend"] = +""

  until work.empty?
    if state["phase"] == "content"
      earliest = earliest_open(work)
      if earliest.nil?
        hold = longest_open_prefix_suffix(work)
        emit_len = work.length - hold.length
        content_out << work[0, emit_len] if emit_len.positive?
        state["pend"] = hold
        break
      end

      idx, spec = earliest
      olen = spec[:open].length
      content_out << work[0, idx] if idx.positive?
      work = work[(idx + olen)..] || +""
      work = work[1..] if work.start_with?("\n")
      state["phase"] = "thought"
      state["open_key"] = spec[:key]
      next
    end

    spec = OPEN_SPECS.find { |s| s[:key] == state["open_key"] }
    idx = work.index(spec[:close])
    if idx.nil?
      hold = longest_close_prefix_suffix(work, spec[:close])
      emit_len = work.length - hold.length
      part = emit_len.positive? ? work[0, emit_len] : +""
      deltas << part if part != ""
      state["pend"] = hold
      break
    end

    clen = spec[:close].length
    part = work[0, idx]
    deltas << part if part != ""
    work = work[(idx + clen)..] || +""
    work = work[1..] if work.start_with?("\n")
    state["phase"] = "content"
    state["open_key"] = nil
  end

  [content_out, state, deltas]
end