Class: Pikuri::Mcp::Synthesizer

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/mcp/synthesizer.rb

Overview

One-shot LLM synthesis of a short “what does this MCP server do” description for the <available_mcps> block, paired with the on-disk cache that persists the result. Owns the prompt, its version, the cleanup (whitespace collapse), and the fail-soft contract — Servers just calls #call and treats a nil return as “fall back.”

Cancellation

Cancellable::Cancelled raised from inside the thinker (the Thinker pre-call check) propagates up through #call (it’s specifically not caught by the StandardError rescue) so a user’s Ctrl+C during a boot-time synthesis pass aborts startup cleanly rather than being silently logged as a “synthesis failure.”

Fail-soft on errors

Any other StandardError — a flaky LLM, garbage JSON, a cache write failure — is logged at WARN and #call returns nil. Pikuri::Mcp::Servers#resolve_description treats nil as the signal to fall back to serverInfo.name, so a single transient blip doesn’t take a server out of the <available_mcps> listing.

Constant Summary collapse

PROMPT_VERSION =

Bump when #build_prompt changes meaningfully. Cache folds this into its key fingerprint via the prompt_version: initializer kwarg, so a bump invalidates every cached entry from the previous prompt without anyone having to rm the cache directory. Version 2: the thinker gained a generic system prompt (Thinker::SYSTEM_PROMPT), changing the effective prompt for every entry.

2

Instance Method Summary collapse

Constructor Details

#initialize(transport: nil, cancellable: nil, thinker: nil, cache: nil) ⇒ Synthesizer

The easy path is Synthesizer.new(transport: …) — the Thinker and the production on-disk Cache are built right here, so wiring one up needs nothing beyond the transport the host agent already has. thinker: exists as the explicit override (a custom callable, a test fake) and is mutually exclusive with the transport path.

Parameters:

  • transport (Pikuri::Agent::ChatTransport, nil) (defaults to: nil)

    when set, a Thinker is constructed from it (and cancellable:); the synthesis passes run against this model.

  • cancellable (Pikuri::Agent::Control::Cancellable, nil) (defaults to: nil)

    forwarded to the constructed Thinker so a boot-time Ctrl+C aborts the pass. Only meaningful on the transport: path.

  • thinker (#call, nil) (defaults to: nil)

    one-arg callable invoked as thinker.call(prompt), replacing the built-in Thinker.

  • cache (Cache, Cache::NULL, nil) (defaults to: nil)

    storage layer. When nil (the default): the transport: path builds the production on-disk Cache keyed on the transport’s model and PROMPT_VERSION; the thinker: path falls back to Cache::NULL (no persistence — the test default).

Raises:

  • (ArgumentError)

    when neither or both of transport: and thinker: are given, or when cancellable: is combined with thinker:



68
69
70
71
72
73
74
75
# File 'lib/pikuri/mcp/synthesizer.rb', line 68

def initialize(transport: nil, cancellable: nil, thinker: nil, cache: nil)
  raise ArgumentError, 'pass exactly one of transport: or thinker:' if transport.nil? == thinker.nil?
  raise ArgumentError, 'cancellable: only applies to the transport: path' if thinker && cancellable

  @thinker = thinker || Thinker.new(transport: transport, cancellable: cancellable)
  @cache = cache ||
           (transport ? Cache.new(model_id: transport.model, prompt_version: PROMPT_VERSION) : Cache::NULL)
end

Instance Method Details

#call(entry:, client:, tools:) ⇒ String?

Produce the description for one server. Returns the cleaned description String, or nil when the thinker raised StandardError, returned blank, or otherwise failed to produce anything usable. Pikuri::Mcp::Servers#resolve_description treats nil as “fall back to serverInfo.name.”

Parameters:

Returns:

  • (String, nil)

Raises:

  • (Pikuri::Agent::Control::Cancellable::Cancelled)

    when the underlying thinker raises Cancelled — propagated so boot-time Ctrl+C aborts startup.



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/pikuri/mcp/synthesizer.rb', line 90

def call(entry:, client:, tools:)
  raw = @cache.fetch(entry: entry, client: client, tools: tools) do
    # Only fires on cache miss — synthesis is the slow path (LLM
    # round-trip, easily 30+ s on a local model); a heads-up is
    # warranted so the user doesn't wonder if pikuri is hung.
    # Cache hits skip this and the thinker.call entirely.
    LOGGER.info("Synthesizing description for MCP server #{entry.id.inspect}, please wait...")
    @thinker.call(build_prompt(entry, tools))
  end
  cleaned = raw.to_s.strip.gsub(/\s+/, ' ')
  return nil if cleaned.empty?

  cleaned
rescue Agent::Control::Cancellable::Cancelled
  raise
rescue StandardError => e
  LOGGER.warn(
    "MCP description synthesis failed for #{entry.id.inspect} " \
    "(#{e.class}: #{e.message}); falling back to serverInfo.name."
  )
  nil
end