solid_agent 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +321 -0
  5. data/Rakefile +32 -0
  6. data/docs/agent-md-spec.md +803 -0
  7. data/docs/parser-design.md +1369 -0
  8. data/docs/registry-api.md +882 -0
  9. data/examples/README.md +60 -0
  10. data/examples/manifests/changelog_writer.agent.md +81 -0
  11. data/examples/manifests/usage.rb +96 -0
  12. data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
  13. data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
  14. data/examples/memory_handoff/usage.rb +45 -0
  15. data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
  16. data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
  17. data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
  18. data/examples/persistent_conversation/usage.rb +51 -0
  19. data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
  20. data/examples/reasoning/usage.rb +52 -0
  21. data/examples/run_tracking/app/agents/report_agent.rb +30 -0
  22. data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
  23. data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
  24. data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
  25. data/examples/run_tracking/usage.rb +85 -0
  26. data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
  27. data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
  28. data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
  29. data/examples/tool_streaming/usage.rb +47 -0
  30. data/lib/generators/solid_agent/agent/agent_generator.rb +95 -0
  31. data/lib/generators/solid_agent/agent/templates/action.text.erb +10 -0
  32. data/lib/generators/solid_agent/agent/templates/agent.rb.erb +93 -0
  33. data/lib/generators/solid_agent/context/context_generator.rb +124 -0
  34. data/lib/generators/solid_agent/context/templates/context_model.rb.erb +134 -0
  35. data/lib/generators/solid_agent/context/templates/create_context.rb.erb +32 -0
  36. data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +46 -0
  37. data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +37 -0
  38. data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +51 -0
  39. data/lib/generators/solid_agent/context/templates/message_model.rb.erb +47 -0
  40. data/lib/generators/solid_agent/install/install_generator.rb +92 -0
  41. data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +171 -0
  42. data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +76 -0
  43. data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
  44. data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
  45. data/lib/generators/solid_agent/install/templates/agent_message.rb.erb +76 -0
  46. data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
  47. data/lib/generators/solid_agent/install/templates/create_agent_contexts.rb.erb +32 -0
  48. data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +51 -0
  49. data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
  50. data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +38 -0
  51. data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
  52. data/lib/generators/solid_agent/install/templates/initializer.rb.erb +51 -0
  53. data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
  54. data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
  55. data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
  56. data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
  57. data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
  58. data/lib/generators/solid_agent/tool/templates/tool.json.erb +19 -0
  59. data/lib/generators/solid_agent/tool/tool_generator.rb +117 -0
  60. data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
  61. data/lib/solid_agent/agent_manifest/errors.rb +26 -0
  62. data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
  63. data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
  64. data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
  65. data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
  66. data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
  67. data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
  68. data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
  69. data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
  70. data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
  71. data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
  72. data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
  73. data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
  74. data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
  75. data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
  76. data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
  77. data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
  78. data/lib/solid_agent/agent_manifest/resource.rb +103 -0
  79. data/lib/solid_agent/agent_manifest/tool.rb +160 -0
  80. data/lib/solid_agent/agent_manifest/validator.rb +368 -0
  81. data/lib/solid_agent/agent_manifest.rb +381 -0
  82. data/lib/solid_agent/engine.rb +16 -0
  83. data/lib/solid_agent/has_context.rb +670 -0
  84. data/lib/solid_agent/has_memory.rb +136 -0
  85. data/lib/solid_agent/has_reasons.rb +230 -0
  86. data/lib/solid_agent/has_tools.rb +257 -0
  87. data/lib/solid_agent/model_naming.rb +42 -0
  88. data/lib/solid_agent/model_pricing.rb +93 -0
  89. data/lib/solid_agent/reasonable/reason.rb +205 -0
  90. data/lib/solid_agent/reasonable.rb +181 -0
  91. data/lib/solid_agent/records/agent.rb +520 -0
  92. data/lib/solid_agent/records/agent_run.rb +520 -0
  93. data/lib/solid_agent/records/agent_template.rb +142 -0
  94. data/lib/solid_agent/records/agent_version.rb +141 -0
  95. data/lib/solid_agent/records/ownable.rb +130 -0
  96. data/lib/solid_agent/records.rb +152 -0
  97. data/lib/solid_agent/run_fingerprint.rb +51 -0
  98. data/lib/solid_agent/streams_tool_updates.rb +178 -0
  99. data/lib/solid_agent/tool_cache.rb +91 -0
  100. data/lib/solid_agent/version.rb +5 -0
  101. data/lib/solid_agent.rb +95 -0
  102. data/sig/solid_agent.rbs +4 -0
  103. metadata +174 -14
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidAgent
4
+ module AgentManifest
5
+ module Parsers
6
+ # BaseParser provides common functionality for all format parsers.
7
+ #
8
+ # Subclasses should implement:
9
+ # - .parse_string(content) - Parse content string into a Manifest
10
+ # - .format_name - Return the format identifier symbol
11
+ #
12
+ class BaseParser
13
+ FRONTMATTER_REGEX = /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
14
+
15
+ class << self
16
+ # Parse a file into a Manifest
17
+ #
18
+ # @param path [String] Path to the file
19
+ # @return [Manifest] Parsed manifest
20
+ def parse(path)
21
+ content = File.read(path, encoding: "UTF-8")
22
+ manifest = parse_string(content)
23
+
24
+ # Handle array results (e.g., CrewAI with multiple agents)
25
+ if manifest.is_a?(Array)
26
+ manifest.each do |m|
27
+ m.source_path = path
28
+ m.source_format = format_name
29
+ end
30
+ else
31
+ manifest.source_path = path
32
+ manifest.source_format = format_name
33
+ end
34
+
35
+ manifest
36
+ end
37
+
38
+ # Parse content string into a Manifest
39
+ #
40
+ # @param content [String] File content
41
+ # @return [Manifest] Parsed manifest
42
+ def parse_string(content)
43
+ raise NotImplementedError, "#{self}.parse_string must be implemented by subclass"
44
+ end
45
+
46
+ # Get the format name for this parser
47
+ #
48
+ # @return [Symbol]
49
+ def format_name
50
+ raise NotImplementedError, "#{self}.format_name must be implemented by subclass"
51
+ end
52
+
53
+ protected
54
+
55
+ # Extract YAML frontmatter from markdown content
56
+ #
57
+ # @param content [String] Full content with frontmatter
58
+ # @return [Array<Hash, String>] [frontmatter_hash, body]
59
+ def extract_frontmatter(content)
60
+ return [{}, content.strip] if content.blank?
61
+
62
+ match = content.match(FRONTMATTER_REGEX)
63
+
64
+ unless match
65
+ # Check if frontmatter was intended but malformed
66
+ if content.strip.start_with?("---")
67
+ raise ParseError, "Malformed frontmatter: missing closing '---' delimiter"
68
+ end
69
+ return [{}, content.strip]
70
+ end
71
+
72
+ begin
73
+ frontmatter = YAML.safe_load(
74
+ match[1],
75
+ permitted_classes: [Symbol, Date, Time],
76
+ permitted_symbols: [],
77
+ aliases: false
78
+ )
79
+ rescue Psych::SyntaxError => e
80
+ raise ParseError, "Invalid YAML in frontmatter: #{e.message}"
81
+ end
82
+
83
+ [frontmatter || {}, match[2].strip]
84
+ end
85
+
86
+ # Normalize model identifier to provider/model format
87
+ #
88
+ # @param model_string [String, nil] Model identifier
89
+ # @return [String, nil] Normalized model identifier
90
+ def normalize_model(model_string)
91
+ return nil unless model_string.present?
92
+
93
+ model_string = model_string.to_s.strip
94
+
95
+ # Already in provider/model format
96
+ return model_string if model_string.include?("/")
97
+
98
+ # Detect provider from model name
99
+ if model_string.start_with?("gpt-") || model_string.start_with?("o1") || model_string.start_with?("o3")
100
+ "openai/#{model_string}"
101
+ elsif model_string.start_with?("claude-")
102
+ "anthropic/#{model_string}"
103
+ elsif model_string.start_with?("gemini-")
104
+ "google/#{model_string}"
105
+ elsif model_string.start_with?("llama") || model_string.start_with?("mixtral")
106
+ "meta/#{model_string}"
107
+ else
108
+ # Unknown - return as-is
109
+ model_string
110
+ end
111
+ end
112
+
113
+ # Parse tools array from frontmatter data
114
+ #
115
+ # @param tools_data [Array, nil] Tools data from frontmatter
116
+ # @return [Array<Tool>] Parsed tools
117
+ def parse_tools(tools_data)
118
+ return [] unless tools_data.is_a?(Array)
119
+
120
+ tools_data.map do |tool_data|
121
+ if tool_data.is_a?(String)
122
+ # String reference
123
+ Tool.new(ref: tool_data)
124
+ elsif tool_data["$ref"]
125
+ # Explicit reference
126
+ Tool.new(ref: tool_data["$ref"])
127
+ else
128
+ # Inline definition
129
+ Tool.from_hash(tool_data)
130
+ end
131
+ end
132
+ end
133
+
134
+ # Parse resources array from frontmatter data
135
+ #
136
+ # @param resources_data [Array, nil] Resources data from frontmatter
137
+ # @return [Array<Resource>] Parsed resources
138
+ def parse_resources(resources_data)
139
+ return [] unless resources_data.is_a?(Array)
140
+
141
+ resources_data.map { |data| Resource.from_hash(data) }
142
+ end
143
+
144
+ # Parse input schema from frontmatter data
145
+ #
146
+ # @param input_data [Hash, nil] Input schema data
147
+ # @return [InputSchema, nil] Parsed input schema
148
+ def parse_input_schema(input_data)
149
+ return nil unless input_data.is_a?(Hash)
150
+
151
+ schema = input_data["schema"] || input_data[:schema]
152
+ return nil unless schema
153
+
154
+ format = InputSchema.detect_format(schema)
155
+ InputSchema.new(schema: schema, format: format)
156
+ end
157
+
158
+ # Extract framework extensions from frontmatter
159
+ #
160
+ # @param frontmatter [Hash] Full frontmatter hash
161
+ # @return [Hash] Framework extensions
162
+ def extract_extensions(frontmatter)
163
+ extensions = {}
164
+
165
+ %w[activeagent crewai langchain genkit dotprompt].each do |framework|
166
+ value = frontmatter[framework] || frontmatter[framework.to_sym]
167
+ extensions[framework.to_sym] = value if value.present?
168
+ end
169
+
170
+ extensions
171
+ end
172
+
173
+ # Extract instructions from markdown body
174
+ #
175
+ # @param body [String] Markdown body
176
+ # @return [String, nil] Extracted instructions
177
+ def extract_instructions(body)
178
+ return nil if body.blank?
179
+
180
+ # Try to find ## Instructions section
181
+ if body =~ /##\s*Instructions\s*\n(.*?)(?=\n##|\z)/mi
182
+ return ::Regexp.last_match(1).strip
183
+ end
184
+
185
+ # Try ## Instruct (abbreviated)
186
+ if body =~ /##\s*Instruct\w*\s*\n(.*?)(?=\n##|\z)/mi
187
+ return ::Regexp.last_match(1).strip
188
+ end
189
+
190
+ # Fall back to body without title
191
+ body.gsub(/\A#\s+[^\n]+\n+/, "").strip.presence
192
+ end
193
+
194
+ # Parse config/model parameters
195
+ #
196
+ # @param frontmatter [Hash] Frontmatter hash
197
+ # @return [Hash] Config hash
198
+ def parse_config(frontmatter)
199
+ config = frontmatter["config"] || frontmatter[:config] || {}
200
+
201
+ # Also check for top-level config keys (Dotprompt style)
202
+ %w[temperature max_tokens maxOutputTokens top_p topP top_k topK stop stopSequences].each do |key|
203
+ value = frontmatter[key] || frontmatter[key.to_sym]
204
+ if value.present?
205
+ # Normalize key names
206
+ normalized_key = case key
207
+ when "maxOutputTokens" then "max_tokens"
208
+ when "topP" then "top_p"
209
+ when "topK" then "top_k"
210
+ when "stopSequences" then "stop"
211
+ else key
212
+ end
213
+ config[normalized_key] = value
214
+ end
215
+ end
216
+
217
+ config.presence || {}
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidAgent
4
+ module AgentManifest
5
+ module Parsers
6
+ # CrewAIParser handles CrewAI's agents.yaml format.
7
+ #
8
+ # CrewAI defines agents with role, goal, and backstory.
9
+ # This parser converts them to the unified Manifest format.
10
+ #
11
+ # @see https://docs.crewai.com
12
+ #
13
+ # @example agents.yaml
14
+ # research_analyst:
15
+ # role: >
16
+ # Senior Research Analyst
17
+ # goal: >
18
+ # Uncover cutting-edge developments
19
+ # backstory: >
20
+ # You're a seasoned researcher...
21
+ # llm: claude-sonnet-4-20250514
22
+ # tools:
23
+ # - SerperDevTool
24
+ #
25
+ class CrewAIParser < BaseParser
26
+ class << self
27
+ def format_name
28
+ :crewai
29
+ end
30
+
31
+ def parse_string(content)
32
+ data = YAML.safe_load(content, permitted_classes: [Symbol])
33
+
34
+ raise ParseError, "Invalid CrewAI YAML: expected hash" unless data.is_a?(Hash)
35
+
36
+ # CrewAI files can contain multiple agents
37
+ agents = data.map do |agent_name, agent_data|
38
+ parse_agent(agent_name.to_s, agent_data)
39
+ end
40
+
41
+ # If single agent, return it directly; otherwise return array
42
+ agents.size == 1 ? agents.first : agents
43
+ end
44
+
45
+ # Parse a file containing multiple agents
46
+ #
47
+ # @param agents_path [String] Path to agents.yaml
48
+ # @param tasks_path [String, nil] Optional path to tasks.yaml
49
+ # @return [Array<Manifest>] Parsed manifests
50
+ def parse_with_tasks(agents_path, tasks_path = nil)
51
+ agents_content = File.read(agents_path, encoding: "UTF-8")
52
+ result = parse_string(agents_content)
53
+
54
+ # Ensure we have an array
55
+ manifests = result.is_a?(Array) ? result : [result]
56
+
57
+ # Optionally merge task information
58
+ if tasks_path && File.exist?(tasks_path)
59
+ tasks = YAML.safe_load(File.read(tasks_path, encoding: "UTF-8"))
60
+ merge_tasks(manifests, tasks)
61
+ end
62
+
63
+ manifests
64
+ end
65
+
66
+ private
67
+
68
+ def parse_agent(name, data)
69
+ return nil unless data.is_a?(Hash)
70
+
71
+ Manifest.new(
72
+ # Convert snake_case name to kebab-case
73
+ name: name.tr("_", "-"),
74
+ description: clean_multiline(data["goal"]),
75
+
76
+ # Model
77
+ model: normalize_model(data["llm"]),
78
+ config: parse_crewai_config(data),
79
+
80
+ # Build instructions from role + backstory
81
+ instructions: build_instructions(data),
82
+ template: build_template(data),
83
+
84
+ # Tools (as references to Python classes)
85
+ tools: parse_crewai_tools(data["tools"]),
86
+
87
+ # Preserve CrewAI-specific config
88
+ extensions: {
89
+ crewai: build_crewai_extensions(data)
90
+ }
91
+ )
92
+ end
93
+
94
+ # Parse CrewAI config options
95
+ def parse_crewai_config(data)
96
+ config = {}
97
+
98
+ # CrewAI doesn't have direct model config in YAML typically
99
+ # but some implementations support it
100
+ config[:temperature] = data["temperature"] if data["temperature"]
101
+ config[:max_tokens] = data["max_tokens"] if data["max_tokens"]
102
+
103
+ config.compact
104
+ end
105
+
106
+ # Build instructions from role and backstory
107
+ def build_instructions(data)
108
+ parts = []
109
+
110
+ role = clean_multiline(data["role"])
111
+ goal = clean_multiline(data["goal"])
112
+ backstory = clean_multiline(data["backstory"])
113
+
114
+ parts << "Role: #{role}" if role.present?
115
+ parts << "Goal: #{goal}" if goal.present?
116
+ parts << "\n#{backstory}" if backstory.present?
117
+
118
+ parts.join("\n").strip
119
+ end
120
+
121
+ # Build a markdown template from CrewAI data
122
+ def build_template(data)
123
+ role = clean_multiline(data["role"]) || "Agent"
124
+ goal = clean_multiline(data["goal"])
125
+ backstory = clean_multiline(data["backstory"])
126
+
127
+ template = "# #{role}\n\n"
128
+ template += "## Goal\n\n#{goal}\n\n" if goal
129
+ template += "## Backstory\n\n#{backstory}\n\n" if backstory
130
+ template
131
+ end
132
+
133
+ # Parse CrewAI tool references
134
+ def parse_crewai_tools(tools_data)
135
+ return [] unless tools_data.is_a?(Array)
136
+
137
+ tools_data.map do |tool|
138
+ if tool.is_a?(String)
139
+ # Reference to a Python tool class
140
+ Tool.new(ref: "crewai://#{tool}")
141
+ elsif tool.is_a?(Hash)
142
+ Tool.from_hash(tool)
143
+ else
144
+ nil
145
+ end
146
+ end.compact
147
+ end
148
+
149
+ # Build CrewAI-specific extensions
150
+ def build_crewai_extensions(data)
151
+ {
152
+ role: clean_multiline(data["role"]),
153
+ goal: clean_multiline(data["goal"]),
154
+ backstory: clean_multiline(data["backstory"]),
155
+ verbose: data["verbose"],
156
+ allow_delegation: data["allow_delegation"],
157
+ max_iter: data["max_iter"],
158
+ max_rpm: data["max_rpm"],
159
+ memory: data["memory"],
160
+ cache: data["cache"],
161
+ step_callback: data["step_callback"],
162
+ system_template: data["system_template"],
163
+ prompt_template: data["prompt_template"],
164
+ response_template: data["response_template"]
165
+ }.compact
166
+ end
167
+
168
+ # Clean up YAML multiline strings
169
+ def clean_multiline(value)
170
+ return nil unless value
171
+ value.to_s.strip.gsub(/\s+/, " ")
172
+ end
173
+
174
+ # Merge task information into manifests
175
+ def merge_tasks(manifests, tasks)
176
+ # Tasks in CrewAI are associated with agents
177
+ # This is a simplified merge - full implementation would be more complex
178
+ tasks.each do |task_name, task_data|
179
+ agent_name = task_data["agent"]&.tr("_", "-")
180
+ manifest = manifests.find { |m| m.name == agent_name }
181
+
182
+ if manifest
183
+ # Add task information to extensions
184
+ manifest.extensions[:crewai] ||= {}
185
+ manifest.extensions[:crewai][:tasks] ||= []
186
+ manifest.extensions[:crewai][:tasks] << {
187
+ name: task_name,
188
+ description: task_data["description"],
189
+ expected_output: task_data["expected_output"]
190
+ }
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+
197
+ # Register the parser
198
+ ParserRegistry.register(:crewai, CrewAIParser)
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidAgent
4
+ module AgentManifest
5
+ module Parsers
6
+ # DotpromptParser handles Google Genkit's .prompt format.
7
+ #
8
+ # Dotprompt files use YAML frontmatter with Handlebars-style templates.
9
+ # This parser converts them to the unified Manifest format.
10
+ #
11
+ # @see https://github.com/google/dotprompt
12
+ #
13
+ # @example Basic .prompt file
14
+ # ---
15
+ # model: googleai/gemini-1.5-pro
16
+ # input:
17
+ # schema:
18
+ # text: string
19
+ # output:
20
+ # format: json
21
+ # ---
22
+ #
23
+ # Extract information from: {{text}}
24
+ #
25
+ class DotpromptParser < BaseParser
26
+ class << self
27
+ def format_name
28
+ :dotprompt
29
+ end
30
+
31
+ def parse_string(content)
32
+ frontmatter, body = extract_frontmatter(content)
33
+
34
+ # Extract name from frontmatter or generate from content
35
+ name = frontmatter["name"] || generate_name_from_content(body)
36
+
37
+ Manifest.new(
38
+ name: name,
39
+ version: frontmatter["version"] || "1.0.0",
40
+ description: frontmatter["description"],
41
+
42
+ # Model - Dotprompt format
43
+ model: normalize_model(frontmatter["model"]),
44
+ config: parse_dotprompt_config(frontmatter),
45
+
46
+ # Schemas
47
+ input_schema: parse_input_schema(frontmatter["input"]),
48
+ output_schema: parse_output_schema(frontmatter["output"]),
49
+
50
+ # Tools (Dotprompt can reference tools)
51
+ tools: parse_tools(frontmatter["tools"]),
52
+
53
+ # Template
54
+ instructions: body,
55
+ template: body,
56
+
57
+ # Store original Dotprompt config in extensions
58
+ extensions: {
59
+ dotprompt: extract_dotprompt_extensions(frontmatter)
60
+ }
61
+ )
62
+ end
63
+
64
+ private
65
+
66
+ # Generate a name from template content
67
+ def generate_name_from_content(body)
68
+ # Try to extract from first line/heading
69
+ if body =~ /\A#\s+(.+)/
70
+ ::Regexp.last_match(1).strip.downcase.gsub(/\s+/, "-").gsub(/[^a-z0-9-]/, "")
71
+ else
72
+ "unnamed-prompt"
73
+ end
74
+ end
75
+
76
+ # Parse Dotprompt-specific config fields
77
+ def parse_dotprompt_config(frontmatter)
78
+ config = {}
79
+
80
+ # Map Dotprompt field names to normalized names
81
+ config[:temperature] = frontmatter["temperature"] if frontmatter["temperature"]
82
+ config[:max_tokens] = frontmatter["maxOutputTokens"] if frontmatter["maxOutputTokens"]
83
+ config[:top_p] = frontmatter["topP"] if frontmatter["topP"]
84
+ config[:top_k] = frontmatter["topK"] if frontmatter["topK"]
85
+ config[:stop] = frontmatter["stopSequences"] if frontmatter["stopSequences"]
86
+
87
+ # Also support normalized names
88
+ config[:temperature] ||= frontmatter["config"]&.dig("temperature")
89
+ config[:max_tokens] ||= frontmatter["config"]&.dig("max_tokens")
90
+
91
+ config.compact
92
+ end
93
+
94
+ # Parse Dotprompt output schema
95
+ def parse_output_schema(output_data)
96
+ return nil unless output_data.is_a?(Hash)
97
+
98
+ {
99
+ format: output_data["format"],
100
+ schema: output_data["schema"]
101
+ }.compact.presence
102
+ end
103
+
104
+ # Extract Dotprompt-specific extensions that don't map to standard fields
105
+ def extract_dotprompt_extensions(frontmatter)
106
+ extensions = {}
107
+
108
+ # Preserve Dotprompt-specific fields
109
+ %w[candidates cache default variant metadata].each do |key|
110
+ extensions[key] = frontmatter[key] if frontmatter[key].present?
111
+ end
112
+
113
+ extensions.presence
114
+ end
115
+ end
116
+ end
117
+
118
+ # Register the parser
119
+ ParserRegistry.register(:dotprompt, DotpromptParser)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidAgent
4
+ module AgentManifest
5
+ module Parsers
6
+ # GitHubPromptParser handles GitHub Copilot's .prompt.md format.
7
+ #
8
+ # GitHub Copilot prompt files use Markdown with YAML frontmatter,
9
+ # supporting variables, tool references, and agent configuration.
10
+ #
11
+ # @see https://docs.github.com/en/copilot/tutorials/customization-library/prompt-files
12
+ #
13
+ # @example .prompt.md file
14
+ # ---
15
+ # model: GPT-4o
16
+ # tools: ['githubRepo', 'search/codebase']
17
+ # description: 'Generate a new React form component'
18
+ # ---
19
+ #
20
+ # Create a React form component for ${input:formName}
21
+ #
22
+ class GitHubPromptParser < BaseParser
23
+ class << self
24
+ def format_name
25
+ :github_prompt
26
+ end
27
+
28
+ def parse_string(content)
29
+ frontmatter, body = extract_frontmatter(content)
30
+
31
+ Manifest.new(
32
+ name: extract_name(frontmatter, body),
33
+ description: frontmatter["description"],
34
+ version: "1.0.0",
35
+
36
+ # Model
37
+ model: normalize_github_model(frontmatter["model"]),
38
+ config: {},
39
+
40
+ # Tools are GitHub-specific references
41
+ tools: parse_github_tools(frontmatter["tools"]),
42
+
43
+ # Template with GitHub variable syntax
44
+ instructions: body,
45
+ template: body,
46
+
47
+ # Preserve GitHub-specific config
48
+ extensions: {
49
+ github_prompt: extract_github_extensions(frontmatter)
50
+ }
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ # Extract name from frontmatter or generate from content
57
+ def extract_name(frontmatter, body)
58
+ return frontmatter["name"] if frontmatter["name"].present?
59
+
60
+ # Try to extract from description
61
+ if frontmatter["description"].present?
62
+ return frontmatter["description"]
63
+ .downcase
64
+ .gsub(/[^a-z0-9\s-]/, "")
65
+ .gsub(/\s+/, "-")
66
+ .slice(0, 50)
67
+ end
68
+
69
+ # Try to extract from first heading
70
+ if body =~ /\A#\s+(.+)/
71
+ return ::Regexp.last_match(1)
72
+ .strip
73
+ .downcase
74
+ .gsub(/[^a-z0-9\s-]/, "")
75
+ .gsub(/\s+/, "-")
76
+ end
77
+
78
+ "github-prompt"
79
+ end
80
+
81
+ # Normalize GitHub model names
82
+ def normalize_github_model(model)
83
+ return nil unless model.present?
84
+
85
+ model_str = model.to_s.strip
86
+
87
+ # GitHub uses simplified model names
88
+ case model_str.downcase
89
+ when "gpt-4o", "gpt4o"
90
+ "openai/gpt-4o"
91
+ when "gpt-4", "gpt4"
92
+ "openai/gpt-4"
93
+ when "gpt-3.5-turbo", "gpt35turbo"
94
+ "openai/gpt-3.5-turbo"
95
+ when /\Aclaude/
96
+ "anthropic/#{model_str.downcase}"
97
+ else
98
+ normalize_model(model_str)
99
+ end
100
+ end
101
+
102
+ # Parse GitHub tool references
103
+ def parse_github_tools(tools_data)
104
+ return [] unless tools_data.is_a?(Array)
105
+
106
+ tools_data.map do |tool|
107
+ tool_name = tool.to_s
108
+ Tool.new(ref: "github://#{tool_name}")
109
+ end
110
+ end
111
+
112
+ # Extract GitHub-specific extensions
113
+ def extract_github_extensions(frontmatter)
114
+ {
115
+ agent: frontmatter["agent"],
116
+ mode: frontmatter["mode"],
117
+ variables: extract_variables(frontmatter),
118
+ # Preserve any other GitHub-specific fields
119
+ original_tools: frontmatter["tools"]
120
+ }.compact
121
+ end
122
+
123
+ # Extract variable definitions
124
+ def extract_variables(frontmatter)
125
+ variables = {}
126
+
127
+ # GitHub prompts can define input variables
128
+ frontmatter.each do |key, value|
129
+ next unless key.to_s.start_with?("input:")
130
+ var_name = key.to_s.sub("input:", "")
131
+ variables[var_name] = value
132
+ end
133
+
134
+ variables.presence
135
+ end
136
+ end
137
+ end
138
+
139
+ # Register the parser
140
+ ParserRegistry.register(:github_prompt, GitHubPromptParser)
141
+ end
142
+ end
143
+ end