Module: AgentHarness::Extensions::Adapters::Pi

Defined in:
lib/agent_harness/extensions.rb

Class Method Summary collapse

Class Method Details

.convention_extension_paths(root) ⇒ Object



438
439
440
441
442
443
# File 'lib/agent_harness/extensions.rb', line 438

def convention_extension_paths(root)
  extensions_dir = File.join(root, "extensions")
  return direct_extension_entry_paths(root) unless File.directory?(extensions_dir)

  direct_extension_entry_paths(extensions_dir)
end

.direct_extension_entry_paths(path) ⇒ Object



453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
# File 'lib/agent_harness/extensions.rb', line 453

def direct_extension_entry_paths(path)
  if File.file?(path)
    return [path] if extension_script?(path)
    return []
  end

  return [] unless File.directory?(path)

  entries = []
  entries.concat(Dir.glob(File.join(path, "*.{ts,js}")))
  Dir.glob(File.join(path, "*")).sort.each do |child|
    next unless File.directory?(child)

    %w[index.ts index.js].each do |entry|
      entry_path = File.join(child, entry)
      entries << entry_path if File.file?(entry_path)
    end
  end
  entries
end

.discover_entry_paths(root, package) ⇒ Object

Raises:



424
425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/agent_harness/extensions.rb', line 424

def discover_entry_paths(root, package)
  manifest_entries = Array(package.dig("pi", "extensions"))
  candidates = if manifest_entries.empty?
    convention_extension_paths(root)
  else
    manifest_entries.flat_map { |entry| expand_manifest_entry(root, entry) }
  end

  paths = candidates.select { |candidate| File.file?(candidate) }
  raise ConfigurationError, "No pi extension entry points found in #{root}" if paths.empty?

  paths.uniq.sort
end

.discover_tools(entry_paths) ⇒ Object



478
479
480
481
482
483
484
485
486
487
488
489
490
# File 'lib/agent_harness/extensions.rb', line 478

def discover_tools(entry_paths)
  entry_paths.flat_map do |entry_path|
    source = File.read(entry_path)
    source.scan(/registerTool\s*\(\s*\{(.*?)\}\s*\)/m).filter_map do |match|
      block = match.first
      name = block[/name:\s*["']([^"']+)["']/, 1]
      next unless name

      description = block[/description:\s*["']([^"']+)["']/, 1]
      {name: name, description: description}.compact
    end
  end.uniq
end

.expand_manifest_entry(root, entry) ⇒ Object



445
446
447
448
449
450
451
# File 'lib/agent_harness/extensions.rb', line 445

def expand_manifest_entry(root, entry)
  absolute = File.expand_path(entry, root)
  return direct_extension_entry_paths(absolute) if File.directory?(absolute)
  return Dir.glob(absolute).flat_map { |match| direct_extension_entry_paths(match) } unless File.exist?(absolute)

  direct_extension_entry_paths(absolute)
end

.extension_script?(path) ⇒ Boolean

Returns:

  • (Boolean)


474
475
476
# File 'lib/agent_harness/extensions.rb', line 474

def extension_script?(path)
  %w[.ts .js].include?(File.extname(path))
end

.has_non_inline_register_tool_calls?(entry_paths) ⇒ Boolean

Detect whether any entry path contains registerTool calls that could not be statically extracted as inline object literals. When true, the extension should conservatively require :tool_use even if discover_tools returned an empty list.

Returns:

  • (Boolean)


496
497
498
499
500
501
502
503
# File 'lib/agent_harness/extensions.rb', line 496

def has_non_inline_register_tool_calls?(entry_paths)
  entry_paths.any? do |entry_path|
    source = File.read(entry_path)
    total_calls = source.scan(/registerTool\s*\(/).length
    inline_calls = source.scan(/registerTool\s*\(\s*\{/).length
    total_calls > inline_calls
  end
end

.infer_unsupported_features(entry_paths) ⇒ Object



505
506
507
508
509
510
511
512
513
514
515
516
517
# File 'lib/agent_harness/extensions.rb', line 505

def infer_unsupported_features(entry_paths)
  features = []

  entry_paths.each do |entry_path|
    source = File.read(entry_path)
    features << :commands if source.include?("registerCommand")
    features << :shortcuts if source.include?("registerShortcut")
    features << :ui if source.match?(/ctx\.ui\.|setWidget|setStatus|setTitle/)
    features << :session_persistence if source.match?(/appendEntry|session_start|session_end/)
  end

  features.uniq
end

.load(path) ⇒ Object



359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/agent_harness/extensions.rb', line 359

def load(path)
  resolved_path = File.expand_path(path)
  single_file = File.file?(resolved_path) && %w[.ts .js].include?(File.extname(resolved_path)) &&
    File.basename(resolved_path) != "package.json"

  root = resolve_root(resolved_path)
  package = load_package_json(root)
  entry_paths = if single_file
    # When a specific script file is requested, scope to that file only
    # instead of discovering all siblings in the parent directory.
    [resolved_path]
  else
    discover_entry_paths(root, package)
  end
  ext_config = package.fetch("agent_harness", {})
  tools = ext_config["tools"] || discover_tools(entry_paths)
  system_prompt_additions = Array(ext_config["system_prompt_additions"])
  mcp_servers = Array(ext_config["mcp_servers"])
  required_provider_capabilities = Array(ext_config["required_provider_capabilities"])
  # Conservatively require :tool_use when registerTool is called with non-inline
  # arguments that static extraction cannot parse.
  if !ext_config["tools"] && has_non_inline_register_tool_calls?(entry_paths)
    required_provider_capabilities |= ["tool_use"]
  end
  unsupported_features = Array(ext_config["unsupported_features"])
  unsupported_features |= infer_unsupported_features(entry_paths)

  default_name = single_file ? File.basename(resolved_path, File.extname(resolved_path)) : File.basename(root)

  [
    PiExtension.new(
      name: ext_config["name"] || package["name"] || default_name,
      description: ext_config["description"] || package["description"],
      version: ext_config["version"] || package["version"],
      tools: tools.map { |tool| normalize_tool(tool) },
      system_prompt_additions: system_prompt_additions,
      mcp_servers: mcp_servers.map { |server| normalize_mcp_server(server) },
      required_provider_capabilities: required_provider_capabilities.map(&:to_sym),
      unsupported_features: unsupported_features.map(&:to_sym),
      source_path: root,
      entry_paths: entry_paths
    )
  ]
end

.load_package_json(root) ⇒ Object



415
416
417
418
419
420
421
422
# File 'lib/agent_harness/extensions.rb', line 415

def load_package_json(root)
  package_path = File.join(root, "package.json")
  return {} unless File.exist?(package_path)

  JSON.parse(File.read(package_path))
rescue JSON::ParserError => e
  raise ConfigurationError, "Invalid package.json for pi extension at #{root}: #{e.message}"
end

.normalize_mcp_server(server) ⇒ Object



530
531
532
533
534
535
536
537
# File 'lib/agent_harness/extensions.rb', line 530

def normalize_mcp_server(server)
  case server
  when Hash
    server.transform_keys(&:to_sym)
  else
    raise ConfigurationError, "Unsupported MCP server definition in pi adapter: #{server.inspect}"
  end
end

.normalize_tool(tool) ⇒ Object



519
520
521
522
523
524
525
526
527
528
# File 'lib/agent_harness/extensions.rb', line 519

def normalize_tool(tool)
  case tool
  when Hash
    tool.transform_keys(&:to_sym)
  when String, Symbol
    {name: tool.to_s}
  else
    raise ConfigurationError, "Unsupported tool definition in pi adapter: #{tool.inspect}"
  end
end

.resolve_root(path) ⇒ Object

Raises:



404
405
406
407
408
409
410
411
412
413
# File 'lib/agent_harness/extensions.rb', line 404

def resolve_root(path)
  if File.file?(path)
    return File.dirname(path) if File.basename(path) == "package.json"
    return File.dirname(path) if %w[.ts .js].include?(File.extname(path))
  end

  return path if File.directory?(path)

  raise ConfigurationError, "Unsupported pi extension source: #{path}"
end