From d7e7ad0ec0b8d28b2c715dadee9ef9b1445c1f4b Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Thu, 29 Jan 2026 10:49:54 -0800 Subject: [PATCH 1/2] Add new agent manifests and tests for various formats - Introduced new agent manifests for CrewAI, Dotprompt, GitHub Prompt, and full-featured agents. - Created tests for validating agent manifests, including parsing and schema validation. - Implemented parsers for CrewAI, Dotprompt, and GitHub Prompt formats. - Added validation tests for agent attributes, including name, version, and tool names. - Enhanced the Picoschema conversion tests for JSON schema compatibility. --- Gemfile.lock | 2 +- README.md | 6 +- docs/agent-md-spec.md | 803 ++++++++++ docs/parser-design.md | 1369 +++++++++++++++++ docs/registry-api.md | 882 +++++++++++ .../solid_agent/agent/agent_generator.rb | 4 +- .../solid_agent/agent/templates/agent.rb.erb | 6 +- .../manifest/manifest_generator.rb | 209 +++ .../manifest/templates/agent.md.erb | 39 + .../solid_agent/manifest/templates/prompt.erb | 13 + lib/solid_agent.rb | 8 +- lib/solid_agent/agent_manifest.rb | 182 +++ .../agent_manifest/agent_builder.rb | 323 ++++ lib/solid_agent/agent_manifest/errors.rb | 26 + .../agent_manifest/exporter_registry.rb | 117 ++ .../exporters/agent_md_exporter.rb | 115 ++ .../agent_manifest/exporters/base_exporter.rb | 152 ++ .../exporters/crewai_exporter.rb | 125 ++ .../exporters/dotprompt_exporter.rb | 92 ++ .../agent_manifest/input_schema.rb | 154 ++ lib/solid_agent/agent_manifest/manifest.rb | 252 +++ .../agent_manifest/parser_registry.rb | 185 +++ .../agent_manifest/parsers/agent_md_parser.rb | 87 ++ .../agent_manifest/parsers/base_parser.rb | 223 +++ .../agent_manifest/parsers/crewai_parser.rb | 201 +++ .../parsers/dotprompt_parser.rb | 122 ++ .../parsers/github_prompt_parser.rb | 143 ++ lib/solid_agent/agent_manifest/picoschema.rb | 254 +++ .../agent_manifest/registry/auth.rb | 103 ++ .../agent_manifest/registry/client.rb | 384 +++++ lib/solid_agent/agent_manifest/resource.rb | 103 ++ lib/solid_agent/agent_manifest/tool.rb | 160 ++ lib/solid_agent/agent_manifest/validator.rb | 368 +++++ lib/solid_agent/has_context.rb | 44 +- .../agent_manifest/crewai/agents.yaml | 30 + .../agent_manifest/dotprompt/basic.prompt | 30 + .../github_prompt/copilot.prompt.md | 30 + .../valid/full_featured.agent.md | 126 ++ .../agent_manifest/valid/minimal.agent.md | 7 + .../agent_manifest/valid/with_tools.agent.md | 42 + .../agent_manifest/manifest_test.rb | 88 ++ .../agent_manifest/parser_registry_test.rb | 62 + .../parsers/agent_md_parser_test.rb | 114 ++ .../parsers/crewai_parser_test.rb | 102 ++ .../parsers/dotprompt_parser_test.rb | 99 ++ .../parsers/github_prompt_parser_test.rb | 118 ++ .../agent_manifest/picoschema_test.rb | 134 ++ .../agent_manifest/validator_test.rb | 186 +++ test/solid_agent/has_context_test.rb | 26 +- test/test_helper.rb | 113 ++ 50 files changed, 8516 insertions(+), 47 deletions(-) create mode 100644 docs/agent-md-spec.md create mode 100644 docs/parser-design.md create mode 100644 docs/registry-api.md create mode 100644 lib/generators/solid_agent/manifest/manifest_generator.rb create mode 100644 lib/generators/solid_agent/manifest/templates/agent.md.erb create mode 100644 lib/generators/solid_agent/manifest/templates/prompt.erb create mode 100644 lib/solid_agent/agent_manifest.rb create mode 100644 lib/solid_agent/agent_manifest/agent_builder.rb create mode 100644 lib/solid_agent/agent_manifest/errors.rb create mode 100644 lib/solid_agent/agent_manifest/exporter_registry.rb create mode 100644 lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb create mode 100644 lib/solid_agent/agent_manifest/exporters/base_exporter.rb create mode 100644 lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb create mode 100644 lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb create mode 100644 lib/solid_agent/agent_manifest/input_schema.rb create mode 100644 lib/solid_agent/agent_manifest/manifest.rb create mode 100644 lib/solid_agent/agent_manifest/parser_registry.rb create mode 100644 lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb create mode 100644 lib/solid_agent/agent_manifest/parsers/base_parser.rb create mode 100644 lib/solid_agent/agent_manifest/parsers/crewai_parser.rb create mode 100644 lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb create mode 100644 lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb create mode 100644 lib/solid_agent/agent_manifest/picoschema.rb create mode 100644 lib/solid_agent/agent_manifest/registry/auth.rb create mode 100644 lib/solid_agent/agent_manifest/registry/client.rb create mode 100644 lib/solid_agent/agent_manifest/resource.rb create mode 100644 lib/solid_agent/agent_manifest/tool.rb create mode 100644 lib/solid_agent/agent_manifest/validator.rb create mode 100644 test/fixtures/agent_manifest/crewai/agents.yaml create mode 100644 test/fixtures/agent_manifest/dotprompt/basic.prompt create mode 100644 test/fixtures/agent_manifest/github_prompt/copilot.prompt.md create mode 100644 test/fixtures/agent_manifest/valid/full_featured.agent.md create mode 100644 test/fixtures/agent_manifest/valid/minimal.agent.md create mode 100644 test/fixtures/agent_manifest/valid/with_tools.agent.md create mode 100644 test/solid_agent/agent_manifest/manifest_test.rb create mode 100644 test/solid_agent/agent_manifest/parser_registry_test.rb create mode 100644 test/solid_agent/agent_manifest/parsers/agent_md_parser_test.rb create mode 100644 test/solid_agent/agent_manifest/parsers/crewai_parser_test.rb create mode 100644 test/solid_agent/agent_manifest/parsers/dotprompt_parser_test.rb create mode 100644 test/solid_agent/agent_manifest/parsers/github_prompt_parser_test.rb create mode 100644 test/solid_agent/agent_manifest/picoschema_test.rb create mode 100644 test/solid_agent/agent_manifest/validator_test.rb diff --git a/Gemfile.lock b/Gemfile.lock index 5845dd5..ba9b2d0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,7 @@ PATH remote: . specs: solid_agent (0.1.1) - activeagent (>= 0.1.0) + activeagent (>= 1.0.0) activerecord (>= 7.0) activesupport (>= 7.0) diff --git a/README.md b/README.md index c44fe55..452731e 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ $ gem install solid_agent Generate a new agent with context support: ```bash -$ rails generate solid_agent:agent WritingAssistant --context --context_name conversation --contextable user +$ rails generate solid_agent:agent WritingAssistant --context --context_name conversation --contextual user ``` ### HasContext - Persistent Conversation History @@ -50,10 +50,10 @@ Add database-backed context management to your agents: class WritingAssistantAgent < ApplicationAgent include SolidAgent::HasContext - has_context :conversation, contextable: :user + has_context :conversation, contextual: :user def improve - load_conversation(contextable: current_user) + load_conversation(contextable: current_user) # contextable is the polymorphic association add_conversation_user_message(params[:message]) prompt messages: conversation_messages end diff --git a/docs/agent-md-spec.md b/docs/agent-md-spec.md new file mode 100644 index 0000000..baf465a --- /dev/null +++ b/docs/agent-md-spec.md @@ -0,0 +1,803 @@ +# `.agent.md` Specification + +**Version:** 0.1.0-draft +**Status:** Draft +**Authors:** ActiveAgents Contributors +**License:** Apache 2.0 + +## Overview + +`.agent.md` is an open, portable format for defining AI agents. It uses Markdown with YAML frontmatter to create human-readable, version-control-friendly agent definitions that can be shared across frameworks and platforms. + +### Design Goals + +1. **Human Readable** - Authors can read and write agents without tooling +2. **Portable** - Agents can run on multiple frameworks (ActiveAgent, CrewAI, LangChain, Genkit) +3. **Git-Friendly** - Clean diffs, easy code review, works with existing workflows +4. **Standards-Based** - Builds on existing specs (Dotprompt, MCP, JSON Schema, AGENTS.md) +5. **Extensible** - Framework-specific features via namespaced sections + +## File Structure + +``` +┌─────────────────────────────────────┐ +│ --- │ YAML Frontmatter +│ name: my-agent │ (structured metadata) +│ model: anthropic/claude-sonnet-4-20250514 │ +│ ... │ +│ --- │ +├─────────────────────────────────────┤ +│ # Agent Title │ Markdown Body +│ │ (instructions + prompt template) +│ You are a helpful assistant... │ +│ │ +│ {{#if context}} │ Handlebars templating +│ Consider: {{context}} │ +│ {{/if}} │ +└─────────────────────────────────────┘ +``` + +## Frontmatter Schema + +### Meta Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique identifier (lowercase, hyphens) | +| `version` | string | No | SemVer version (default: "1.0.0") | +| `description` | string | No | Brief description (max 280 chars) | +| `author` | string | No | Author name or organization | +| `license` | string | No | SPDX license identifier | +| `repository` | string | No | Source repository URL | +| `tags` | string[] | No | Categorization tags | +| `extends` | string | No | Parent agent to inherit from | + +```yaml +--- +name: research-assistant +version: 1.2.0 +description: An agent that researches topics and synthesizes findings +author: activeagents +license: MIT +repository: https://github.com/activeagents/research-assistant +tags: [research, web, summarization, rag] +extends: "@activeagents/base-assistant" +--- +``` + +### Model Configuration + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `model` | string | Yes | Model identifier (`provider/model-name`) | +| `config` | object | No | Model-specific parameters | +| `config.temperature` | number | No | Sampling temperature (0.0-2.0) | +| `config.max_tokens` | number | No | Maximum response tokens | +| `config.top_p` | number | No | Nucleus sampling parameter | +| `config.stop` | string[] | No | Stop sequences | + +```yaml +model: anthropic/claude-sonnet-4-20250514 +config: + temperature: 0.7 + max_tokens: 4096 + top_p: 0.9 +``` + +#### Model Identifier Format + +``` +provider/model-name[:version] + +Examples: + anthropic/claude-sonnet-4-20250514 + openai/gpt-4o + google/gemini-2.0-flash + ollama/llama3:70b + azure/gpt-4o:2024-05-13 +``` + +### Input Schema + +Defines the expected input parameters using [Picoschema](#picoschema) (compact) or [JSON Schema](#json-schema) (full). + +```yaml +# Picoschema (compact, Dotprompt-compatible) +input: + schema: + query: string, the research question to investigate + depth?: string(shallow, moderate, deep), how thorough the research should be + max_sources?: integer, maximum number of sources to cite + +# OR JSON Schema (full) +input: + schema: + type: object + properties: + query: + type: string + description: The research question to investigate + depth: + type: string + enum: [shallow, moderate, deep] + default: moderate + max_sources: + type: integer + minimum: 1 + maximum: 20 + default: 5 + required: [query] +``` + +### Output Schema + +Defines the expected output format and structure. + +```yaml +output: + format: json # json | text | markdown + schema: + summary: string, executive summary of findings + confidence: number, confidence score 0-1 + sources: [object], list of referenced sources + url: string, source URL + title: string, page title + snippet: string, relevant excerpt + relevance: number, relevance score 0-1 + follow_up?: [string], suggested follow-up questions +``` + +### Tools Definition + +Tools follow [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) conventions for maximum portability. + +```yaml +tools: + # Inline definition + - name: web_search + description: Search the web for information + inputSchema: + type: object + properties: + query: + type: string + description: Search query + num_results: + type: integer + default: 10 + maximum: 50 + required: [query] + + # Reference to external tool file + - $ref: "./tools/navigate.tool.json" + + # Reference to tool pack + - $ref: "@activeagents/web-tools/search" +``` + +#### Tool File Format (`.tool.json`) + +Standalone tool definitions compatible with MCP: + +```json +{ + "name": "navigate", + "description": "Navigate to a URL and extract page content", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The URL to navigate to" + }, + "extract": { + "type": "string", + "enum": ["text", "html", "markdown"], + "default": "markdown" + } + }, + "required": ["url"] + } +} +``` + +### Resources (MCP-compatible) + +External data sources the agent can access: + +```yaml +resources: + - name: company_docs + description: Internal company documentation + uri: "file:///docs/**/*.md" + mimeType: text/markdown + + - name: api_spec + description: API specification + uri: "https://api.example.com/openapi.json" + mimeType: application/json +``` + +### Framework Extensions + +Framework-specific configuration lives in namespaced sections. Other frameworks SHOULD ignore unrecognized namespaces. + +#### ActiveAgent Extension + +```yaml +activeagent: + class_name: ResearchAssistantAgent + parent_class: ApplicationAgent + concerns: + - has_context: + contextual: user + context_name: research_session + - has_tools: [web_search, navigate, summarize] + - streams_tool_updates: + channel: research_progress + callbacks: + before_prompt: validate_query + after_generation: log_research + queued: true # Run via ActiveJob +``` + +#### CrewAI Extension + +```yaml +crewai: + role: Senior Research Analyst + goal: Conduct thorough research and provide actionable insights + backstory: > + You are a seasoned research analyst with 15 years of experience + in investigative journalism and academic research. + allow_delegation: true + verbose: true +``` + +#### LangChain Extension + +```yaml +langchain: + agent_type: openai-tools + memory: + type: conversation_buffer + max_tokens: 4000 + callbacks: + - langsmith +``` + +## Markdown Body + +The body contains the agent's instructions and prompt template using Markdown with optional Handlebars templating. + +### Structure + +```markdown +# Agent Title + +Brief description of the agent's purpose. + +## Instructions + +Core behavioral instructions for the agent. + +## Guidelines + +Specific rules and constraints. + +## Examples + +{{#if include_examples}} +### Example 1: Basic Query +User: What is quantum computing? +Assistant: [Example response...] +{{/if}} + +## Context + +{{#if context}} +Consider the following context: +{{context}} +{{/if}} + +## Task + +{{task}} +``` + +### Templating + +Uses [Handlebars](https://handlebarsjs.com/) syntax (Dotprompt-compatible): + +| Syntax | Description | +|--------|-------------| +| `{{variable}}` | Insert variable (HTML-escaped) | +| `{{{variable}}}` | Insert variable (raw, unescaped) | +| `{{#if condition}}...{{/if}}` | Conditional block | +| `{{#unless condition}}...{{/unless}}` | Negative conditional | +| `{{#each items}}...{{/each}}` | Iteration | +| `{{> partial}}` | Include partial template | + +#### Built-in Variables + +| Variable | Description | +|----------|-------------| +| `{{input.*}}` | Input schema fields | +| `{{context}}` | Loaded context (if using HasContext) | +| `{{messages}}` | Conversation history | +| `{{tools}}` | Available tool descriptions | +| `{{datetime}}` | Current ISO datetime | +| `{{agent.name}}` | Agent name from frontmatter | + +### Sections + +Reserved section headers with semantic meaning: + +| Section | Purpose | +|---------|---------| +| `# {Title}` | Agent title (H1) | +| `## Instructions` | Core behavioral instructions | +| `## Guidelines` | Rules and constraints | +| `## Examples` | Few-shot examples | +| `## Context` | Dynamic context insertion | +| `## Task` | The specific task template | +| `## Output Format` | Expected output structure | + +## Picoschema + +Picoschema is a compact, YAML-optimized schema format (from Dotprompt): + +### Scalar Types + +| Type | Description | +|------|-------------| +| `string` | Text value | +| `integer` | Whole number | +| `number` | Decimal number | +| `boolean` | true/false | +| `any` | Any type | + +### Modifiers + +| Syntax | Meaning | +|--------|---------| +| `field?` | Optional field | +| `field: type, description` | Field with description | +| `field: type(a, b, c)` | Enum values | +| `field: [type]` | Array of type | +| `field: [object]` | Array of objects (indent children) | + +### Examples + +```yaml +# Simple types +name: string +age: integer +score: number +active: boolean + +# Optional fields +nickname?: string +metadata?: any + +# With descriptions +email: string, user's email address +priority: integer, 1-5 priority level + +# Enums +status: string(draft, published, archived) +size: string(small, medium, large) + +# Arrays +tags: [string] +scores: [number] + +# Nested objects +author: object + name: string + email: string + +# Array of objects +comments: [object] + author: string + text: string + timestamp: string +``` + +## File Organization + +### Single-File Agent + +``` +my-agent.agent.md +``` + +### Multi-File Agent (Package) + +``` +my-agent/ +├── agent.md # Main definition (required) +├── README.md # Human documentation +├── CHANGELOG.md # Version history +├── LICENSE # License file +│ +├── tools/ # Tool definitions +│ ├── search.tool.json +│ └── analyze.tool.json +│ +├── prompts/ # Partial templates +│ ├── _header.md +│ └── _examples.md +│ +├── examples/ # Example inputs/contexts +│ ├── basic.input.json +│ └── advanced.input.json +│ +└── tests/ # Test cases + ├── unit.test.yml + └── integration.test.yml +``` + +### Package Manifest (`agent.json`) + +For published packages: + +```json +{ + "name": "@activeagents/research-assistant", + "version": "1.2.0", + "description": "An agent that researches topics and synthesizes findings", + "main": "agent.md", + "files": [ + "agent.md", + "tools/*.tool.json", + "prompts/*.md" + ], + "keywords": ["research", "web", "summarization"], + "author": "ActiveAgents ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/activeagents/research-assistant" + }, + "dependencies": { + "@activeagents/web-tools": "^1.0.0" + }, + "engines": { + "activeagent": ">=0.5.0" + }, + "compatibility": { + "frameworks": ["activeagent", "crewai", "langchain", "genkit"] + } +} +``` + +## Test Format + +### Test Cases (`*.test.yml`) + +```yaml +name: Research Assistant Tests +agent: ./agent.md + +cases: + - name: basic_research_query + description: Should handle a simple research question + input: + query: "What are the benefits of meditation?" + depth: shallow + expect: + output: + summary: { contains: "meditation" } + sources: { min_length: 2 } + confidence: { gte: 0.7 } + tools_called: [web_search] + no_errors: true + + - name: deep_research_with_sources + description: Should cite multiple sources for deep research + input: + query: "Compare quantum computing approaches" + depth: deep + max_sources: 10 + expect: + output: + sources: { min_length: 5, max_length: 10 } + tools_called: [web_search, navigate] + response_time: { lte: 30000 } # 30 seconds + + - name: handles_invalid_input + description: Should gracefully handle missing query + input: {} + expect: + error: { contains: "query is required" } +``` + +### Context Fixtures (`*.context.json`) + +```json +{ + "name": "research_context_example", + "description": "Example context for testing research agent", + "messages": [ + { + "role": "user", + "content": "Research the history of artificial intelligence" + }, + { + "role": "assistant", + "content": "I'll research the history of AI for you..." + } + ], + "metadata": { + "user_id": "test-user-123", + "session_id": "sess-456" + } +} +``` + +## Registry API + +### Package Discovery + +``` +GET /api/v1/agents +GET /api/v1/agents?q=research&tags=web,rag +GET /api/v1/agents/@activeagents/research-assistant +GET /api/v1/agents/@activeagents/research-assistant/versions +GET /api/v1/agents/@activeagents/research-assistant/1.2.0 +``` + +### Package Publishing + +``` +POST /api/v1/agents +Authorization: Bearer +Content-Type: multipart/form-data + +{package archive} +``` + +### Response Format + +```json +{ + "name": "@activeagents/research-assistant", + "version": "1.2.0", + "description": "An agent that researches topics and synthesizes findings", + "author": { + "name": "ActiveAgents", + "url": "https://activeagents.ai" + }, + "downloads": { + "total": 15420, + "weekly": 342 + }, + "stars": 89, + "license": "MIT", + "tags": ["research", "web", "summarization"], + "compatibility": { + "frameworks": ["activeagent", "crewai", "langchain"], + "models": ["anthropic/*", "openai/*"] + }, + "files": { + "agent.md": "https://cdn.activeagents.ai/...", + "tools/search.tool.json": "https://cdn.activeagents.ai/..." + }, + "checksums": { + "agent.md": "sha256:abc123...", + "tools/search.tool.json": "sha256:def456..." + }, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-20T14:22:00Z" +} +``` + +## CLI Reference + +```bash +# Initialize new agent +activeagent init my-agent +activeagent init my-agent --template research + +# Validate agent definition +activeagent validate my-agent.agent.md +activeagent validate ./my-agent/ + +# Test agent locally +activeagent test my-agent.agent.md +activeagent test my-agent.agent.md --input examples/basic.input.json +activeagent test my-agent.agent.md --context examples/research.context.json + +# Run agent interactively +activeagent run my-agent.agent.md +activeagent run my-agent.agent.md --input '{"query": "test"}' + +# Search registry +activeagent search research +activeagent search --tags web,rag --framework activeagent + +# Install from registry +activeagent add @activeagents/research-assistant +activeagent add @activeagents/research-assistant@1.2.0 + +# Fork agent +activeagent fork @activeagents/research-assistant my-researcher + +# Publish to registry +activeagent login +activeagent publish +activeagent publish --tag beta + +# Export to other formats +activeagent export my-agent.agent.md --format crewai +activeagent export my-agent.agent.md --format dotprompt +activeagent export my-agent.agent.md --format langchain + +# Import from other formats +activeagent import agent.yaml --from crewai +activeagent import prompt.prompt --from dotprompt +``` + +## Compatibility Matrix + +| Feature | ActiveAgent | CrewAI | LangChain | Genkit | +|---------|-------------|--------|-----------|--------| +| Basic metadata | ✅ | ✅ | ✅ | ✅ | +| Model config | ✅ | ✅ | ✅ | ✅ | +| Input schema | ✅ | ⚠️ | ✅ | ✅ | +| Output schema | ✅ | ⚠️ | ✅ | ✅ | +| Tools (MCP) | ✅ | ✅ | ✅ | ✅ | +| Handlebars | ✅ | ⚠️ | ⚠️ | ✅ | +| Framework extensions | ✅ | ✅ | ✅ | ⚠️ | +| Context persistence | ✅ | ❌ | ⚠️ | ❌ | +| Streaming | ✅ | ⚠️ | ✅ | ✅ | + +✅ Full support | ⚠️ Partial/adapter needed | ❌ Not supported + +## Examples + +### Minimal Agent + +```markdown +--- +name: hello-world +model: openai/gpt-4o +--- + +# Hello World Agent + +You are a friendly assistant. Greet the user warmly. +``` + +### Research Agent + +```markdown +--- +name: research-assistant +version: 1.0.0 +model: anthropic/claude-sonnet-4-20250514 +config: + temperature: 0.7 + +input: + schema: + query: string, the research question + depth?: string(shallow, deep) + +output: + format: json + schema: + summary: string + sources: [object] + url: string + title: string + +tools: + - name: web_search + description: Search the web + inputSchema: + type: object + properties: + query: { type: string } + required: [query] + +activeagent: + concerns: + - has_context: + contextual: user + - has_tools: [web_search] +--- + +# Research Assistant + +You are a thorough research assistant. + +## Instructions + +1. Analyze the research query +2. Search for relevant information +3. Synthesize findings with citations + +## Task + +Research the following: + +{{query}} + +{{#if depth == "deep"}} +Provide comprehensive analysis with multiple perspectives. +{{else}} +Provide a concise summary with key points. +{{/if}} +``` + +### Multi-Tool Agent + +```markdown +--- +name: code-reviewer +version: 2.1.0 +model: anthropic/claude-sonnet-4-20250514 + +tools: + - $ref: "@activeagents/code-tools/analyze" + - $ref: "@activeagents/code-tools/search" + - name: suggest_fix + description: Suggest a code fix + inputSchema: + type: object + properties: + file: { type: string } + issue: { type: string } + suggestion: { type: string } + required: [file, issue, suggestion] + +activeagent: + concerns: + - has_tools: [analyze, search, suggest_fix] + - streams_tool_updates: true +--- + +# Code Reviewer + +You are an expert code reviewer focused on quality and security. + +## Review Checklist + +- [ ] Security vulnerabilities +- [ ] Performance issues +- [ ] Code style consistency +- [ ] Test coverage +- [ ] Documentation + +## Task + +Review the following code and provide actionable feedback: + +```{{language}} +{{code}} +``` +``` + +## References + +- [Dotprompt](https://github.com/google/dotprompt) - Google's prompt template format +- [MCP](https://modelcontextprotocol.io/) - Model Context Protocol specification +- [AGENTS.md](https://agents.md/) - OpenAI's agent instructions format +- [JSON Schema](https://json-schema.org/) - Schema validation standard +- [Handlebars](https://handlebarsjs.com/) - Templating language +- [SemVer](https://semver.org/) - Semantic versioning + +## Changelog + +### 0.1.0-draft (2025-01-08) + +- Initial draft specification +- Core frontmatter schema +- Picoschema support +- MCP-compatible tools +- Framework extension namespaces +- Test format definition +- Registry API design diff --git a/docs/parser-design.md b/docs/parser-design.md new file mode 100644 index 0000000..f77d4f4 --- /dev/null +++ b/docs/parser-design.md @@ -0,0 +1,1369 @@ +# Agent Parser Design + +A modular parser architecture that supports multiple agent definition formats with a unified internal representation. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ AgentManifest │ +│ (Unified Internal Model) │ +└─────────────────────────────────────────────────────────────────────┘ + ▲ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ + │ │ │ +┌────────┴────────┐ ┌─────────┴─────────┐ ┌────────┴────────┐ +│ AgentMdParser │ │ DotpromptParser │ │ CrewAIParser │ +│ (.agent.md) │ │ (.prompt) │ │ (agents.yaml) │ +└─────────────────┘ └───────────────────┘ └─────────────────┘ + │ │ │ + │ │ │ +┌────────┴────────┐ ┌─────────┴─────────┐ ┌────────┴────────┐ +│ Format A │ │ Format B │ │ Format C │ +│ .agent.md │ │ .prompt │ │ agents.yaml │ +└─────────────────┘ └───────────────────┘ └─────────────────┘ + + │ + ▼ + ┌───────────────────────┼───────────────────────┐ + │ │ │ +┌────────┴────────┐ ┌─────────┴─────────┐ ┌────────┴────────┐ +│ ActiveAgent │ │ CrewAI │ │ LangChain │ +│ Exporter │ │ Exporter │ │ Exporter │ +└─────────────────┘ └───────────────────┘ └─────────────────┘ +``` + +## Core Components + +### 1. AgentManifest (Unified Model) + +The canonical internal representation that all parsers produce and exporters consume: + +```ruby +# lib/agent_manifest/manifest.rb +module AgentManifest + class Manifest + attr_accessor :name, :version, :description, :author, :license, + :repository, :tags, :extends + + attr_accessor :model, :config + + attr_accessor :input_schema, :output_schema + + attr_accessor :tools, :resources + + attr_accessor :instructions, :template + + attr_accessor :extensions # Framework-specific: { activeagent: {}, crewai: {} } + + attr_accessor :examples, :tests + + attr_accessor :source_format, :source_path # For round-trip preservation + + def initialize(attributes = {}) + attributes.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") } + @extensions ||= {} + @tools ||= [] + @resources ||= [] + @tags ||= [] + @examples ||= [] + @tests ||= [] + end + + def to_h + { + name: name, + version: version, + description: description, + author: author, + license: license, + repository: repository, + tags: tags, + extends: extends, + model: model, + config: config, + input_schema: input_schema, + output_schema: output_schema, + tools: tools.map(&:to_h), + resources: resources.map(&:to_h), + instructions: instructions, + template: template, + extensions: extensions + }.compact + end + + # Convenience accessors for framework extensions + def activeagent_config + extensions[:activeagent] || {} + end + + def crewai_config + extensions[:crewai] || {} + end + + def langchain_config + extensions[:langchain] || {} + end + end + + class Tool + attr_accessor :name, :description, :input_schema, :ref + + def initialize(attributes = {}) + attributes.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") } + end + + def reference? + ref.present? + end + + def to_h + if reference? + { "$ref" => ref } + else + { + name: name, + description: description, + inputSchema: input_schema + }.compact + end + end + + # MCP-compatible JSON output + def to_mcp_json + { + name: name, + description: description, + inputSchema: input_schema + }.to_json + end + end + + class Resource + attr_accessor :name, :description, :uri, :mime_type + + def initialize(attributes = {}) + attributes.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") } + end + + def to_h + { + name: name, + description: description, + uri: uri, + mimeType: mime_type + }.compact + end + end + + class InputSchema + attr_accessor :schema, :format # format: :picoschema or :json_schema + + def initialize(schema:, format: :picoschema) + @schema = schema + @format = format + end + + def to_json_schema + case format + when :picoschema + PicoschemaParser.to_json_schema(schema) + when :json_schema + schema + end + end + + def to_picoschema + case format + when :picoschema + schema + when :json_schema + PicoschemaParser.from_json_schema(schema) + end + end + end +end +``` + +### 2. Parser Registry + +```ruby +# lib/agent_manifest/parser_registry.rb +module AgentManifest + class ParserRegistry + class << self + def parsers + @parsers ||= {} + end + + def register(format, parser_class) + parsers[format.to_sym] = parser_class + end + + def parser_for(format) + parsers[format.to_sym] || raise(UnknownFormatError, "No parser for format: #{format}") + end + + def detect_format(path) + extension = File.extname(path).downcase + case extension + when '.md' + # Could be .agent.md or regular markdown + path.end_with?('.agent.md') ? :agent_md : :markdown + when '.prompt' + :dotprompt + when '.yaml', '.yml' + detect_yaml_format(path) + when '.json' + detect_json_format(path) + else + raise UnknownFormatError, "Cannot detect format for: #{path}" + end + end + + def parse(path, format: nil) + format ||= detect_format(path) + parser_for(format).parse(path) + end + + def parse_string(content, format:) + parser_for(format).parse_string(content) + end + + private + + def detect_yaml_format(path) + content = File.read(path) + if content.include?('role:') && content.include?('goal:') + :crewai + elsif content.include?('has_context') || content.include?('activeagent') + :activeagent_yaml + else + :yaml + end + end + + def detect_json_format(path) + content = JSON.parse(File.read(path)) + if content['inputSchema'] + :mcp_tool + elsif content['main'] && content['files'] + :agent_package + else + :json + end + end + end + end + + class UnknownFormatError < StandardError; end +end +``` + +### 3. Base Parser + +```ruby +# lib/agent_manifest/parsers/base_parser.rb +module AgentManifest + module Parsers + class BaseParser + class << self + def parse(path) + content = File.read(path) + manifest = parse_string(content) + manifest.source_path = path + manifest.source_format = format_name + manifest + end + + def parse_string(content) + raise NotImplementedError, "Subclasses must implement parse_string" + end + + def format_name + raise NotImplementedError, "Subclasses must implement format_name" + end + + protected + + def normalize_model(model_string) + # Normalize model identifiers to provider/model format + return nil unless model_string + + if model_string.include?('/') + model_string + elsif model_string.start_with?('gpt-') + "openai/#{model_string}" + elsif model_string.start_with?('claude-') + "anthropic/#{model_string}" + elsif model_string.start_with?('gemini-') + "google/#{model_string}" + else + model_string + end + end + + def parse_tools(tools_data) + return [] unless tools_data + + tools_data.map do |tool_data| + if tool_data.is_a?(String) + Tool.new(ref: tool_data) + elsif tool_data['$ref'] + Tool.new(ref: tool_data['$ref']) + else + Tool.new( + name: tool_data['name'], + description: tool_data['description'], + input_schema: tool_data['inputSchema'] || tool_data['input_schema'] + ) + end + end + end + end + end + end +end +``` + +## Format Parsers + +### 4. AgentMd Parser (.agent.md) + +```ruby +# lib/agent_manifest/parsers/agent_md_parser.rb +require 'yaml' + +module AgentManifest + module Parsers + class AgentMdParser < BaseParser + FRONTMATTER_REGEX = /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m + + class << self + def format_name + :agent_md + end + + def parse_string(content) + frontmatter, body = extract_frontmatter(content) + + Manifest.new( + # Meta + name: frontmatter['name'], + version: frontmatter['version'] || '1.0.0', + description: frontmatter['description'], + author: frontmatter['author'], + license: frontmatter['license'], + repository: frontmatter['repository'], + tags: frontmatter['tags'] || [], + extends: frontmatter['extends'], + + # Model + model: normalize_model(frontmatter['model']), + config: frontmatter['config'] || {}, + + # Schemas + input_schema: parse_schema(frontmatter['input']), + output_schema: parse_schema(frontmatter['output']), + + # Tools & Resources + tools: parse_tools(frontmatter['tools']), + resources: parse_resources(frontmatter['resources']), + + # Instructions + instructions: extract_instructions(body), + template: body, + + # Extensions + extensions: extract_extensions(frontmatter) + ) + end + + private + + def extract_frontmatter(content) + match = content.match(FRONTMATTER_REGEX) + raise ParseError, "Invalid .agent.md format: missing frontmatter" unless match + + frontmatter = YAML.safe_load(match[1], permitted_classes: [Symbol]) + body = match[2].strip + + [frontmatter, body] + end + + def parse_schema(schema_data) + return nil unless schema_data + + schema = schema_data['schema'] + return nil unless schema + + # Detect if it's Picoschema or JSON Schema + if schema.is_a?(Hash) && schema['type'] + InputSchema.new(schema: schema, format: :json_schema) + else + InputSchema.new(schema: schema, format: :picoschema) + end + end + + def parse_resources(resources_data) + return [] unless resources_data + + resources_data.map do |res| + Resource.new( + name: res['name'], + description: res['description'], + uri: res['uri'], + mime_type: res['mimeType'] + ) + end + end + + def extract_instructions(body) + # Extract content from ## Instructions section + if body =~ /##\s*Instructions\s*\n(.*?)(?=\n##|\z)/mi + $1.strip + else + # Use entire body as instructions if no section found + body.gsub(/^#\s+.*\n/, '').strip + end + end + + def extract_extensions(frontmatter) + extensions = {} + %w[activeagent crewai langchain genkit].each do |framework| + extensions[framework.to_sym] = frontmatter[framework] if frontmatter[framework] + end + extensions + end + end + end + + # Register the parser + ParserRegistry.register(:agent_md, AgentMdParser) + end +end +``` + +### 5. Dotprompt Parser (.prompt) + +```ruby +# lib/agent_manifest/parsers/dotprompt_parser.rb +module AgentManifest + module Parsers + class DotpromptParser < BaseParser + FRONTMATTER_REGEX = /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m + + class << self + def format_name + :dotprompt + end + + def parse_string(content) + frontmatter, body = extract_frontmatter(content) + + # Extract name from filename if not in frontmatter + name = frontmatter['name'] || 'unnamed-prompt' + + Manifest.new( + name: name, + version: frontmatter['version'] || '1.0.0', + + # Model - Dotprompt format + model: normalize_model(frontmatter['model']), + config: extract_config(frontmatter), + + # Schemas - Dotprompt uses input/output directly + input_schema: parse_dotprompt_schema(frontmatter['input']), + output_schema: parse_dotprompt_output(frontmatter['output']), + + # Tools + tools: parse_tools(frontmatter['tools']), + + # Template + instructions: body, + template: body, + + # Store original format info + extensions: { + dotprompt: frontmatter.except('model', 'input', 'output', 'tools') + } + ) + end + + private + + def extract_frontmatter(content) + match = content.match(FRONTMATTER_REGEX) + raise ParseError, "Invalid .prompt format: missing frontmatter" unless match + + [YAML.safe_load(match[1]), match[2].strip] + end + + def extract_config(frontmatter) + config = {} + config[:temperature] = frontmatter['temperature'] if frontmatter['temperature'] + config[:max_tokens] = frontmatter['maxOutputTokens'] if frontmatter['maxOutputTokens'] + config[:top_p] = frontmatter['topP'] if frontmatter['topP'] + config[:top_k] = frontmatter['topK'] if frontmatter['topK'] + config[:stop] = frontmatter['stopSequences'] if frontmatter['stopSequences'] + config + end + + def parse_dotprompt_schema(input_data) + return nil unless input_data + + schema = input_data['schema'] + return nil unless schema + + # Dotprompt uses Picoschema by default + InputSchema.new(schema: schema, format: :picoschema) + end + + def parse_dotprompt_output(output_data) + return nil unless output_data + + { + format: output_data['format'], + schema: output_data['schema'] + } + end + end + end + + ParserRegistry.register(:dotprompt, DotpromptParser) + end +end +``` + +### 6. CrewAI Parser (agents.yaml) + +```ruby +# lib/agent_manifest/parsers/crewai_parser.rb +module AgentManifest + module Parsers + class CrewAIParser < BaseParser + class << self + def format_name + :crewai + end + + def parse_string(content) + data = YAML.safe_load(content) + + # CrewAI files contain multiple agents, return array + agents = data.map do |agent_name, agent_data| + parse_agent(agent_name, agent_data) + end + + # If single agent, return it directly + agents.size == 1 ? agents.first : agents + end + + def parse_file_pair(agents_path, tasks_path = nil) + agents_content = File.read(agents_path) + agents = parse_string(agents_content) + + if tasks_path && File.exist?(tasks_path) + tasks = YAML.safe_load(File.read(tasks_path)) + # Merge task info into agents + # (simplified - real implementation would be more complex) + end + + agents + end + + private + + def parse_agent(name, data) + Manifest.new( + name: name.to_s.underscore.dasherize, + description: data['goal'], + + model: normalize_model(data['llm']), + + # CrewAI doesn't have formal schemas + input_schema: nil, + output_schema: nil, + + # Map tools + tools: parse_crewai_tools(data['tools']), + + # Instructions from role + backstory + instructions: build_instructions(data), + template: build_template(data), + + # Preserve CrewAI-specific config + extensions: { + crewai: { + role: data['role'], + goal: data['goal'], + backstory: data['backstory'], + verbose: data['verbose'], + allow_delegation: data['allow_delegation'], + max_iter: data['max_iter'], + max_rpm: data['max_rpm'] + }.compact + } + ) + end + + def parse_crewai_tools(tools) + return [] unless tools + + tools.map do |tool| + if tool.is_a?(String) + # Reference to a tool class + Tool.new(ref: tool) + else + Tool.new( + name: tool['name'], + description: tool['description'], + input_schema: tool['args_schema'] + ) + end + end + end + + def build_instructions(data) + parts = [] + parts << "Role: #{data['role']}" if data['role'] + parts << "Goal: #{data['goal']}" if data['goal'] + parts << "\n#{data['backstory']}" if data['backstory'] + parts.join("\n") + end + + def build_template(data) + <<~TEMPLATE + # #{data['role']} + + ## Goal + #{data['goal']} + + ## Backstory + #{data['backstory']} + TEMPLATE + end + end + end + + ParserRegistry.register(:crewai, CrewAIParser) + end +end +``` + +### 7. GitHub Prompt Parser (.prompt.md) + +```ruby +# lib/agent_manifest/parsers/github_prompt_parser.rb +module AgentManifest + module Parsers + class GitHubPromptParser < BaseParser + FRONTMATTER_REGEX = /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m + + class << self + def format_name + :github_prompt + end + + def parse_string(content) + frontmatter, body = extract_frontmatter(content) + + Manifest.new( + name: extract_name_from_frontmatter(frontmatter), + description: frontmatter['description'], + + model: normalize_model(frontmatter['model']), + + tools: parse_github_tools(frontmatter['tools']), + + instructions: body, + template: body, + + extensions: { + github_prompt: { + agent: frontmatter['agent'], + mode: frontmatter['mode'] + }.compact + } + ) + end + + private + + def extract_frontmatter(content) + match = content.match(FRONTMATTER_REGEX) + if match + [YAML.safe_load(match[1]), match[2].strip] + else + [{}, content.strip] + end + end + + def extract_name_from_frontmatter(frontmatter) + frontmatter['name'] || 'github-prompt' + end + + def parse_github_tools(tools) + return [] unless tools + + # GitHub tools are string references like 'githubRepo', 'search/codebase' + tools.map do |tool| + Tool.new(ref: "github://#{tool}") + end + end + end + end + + ParserRegistry.register(:github_prompt, GitHubPromptParser) + end +end +``` + +## Format Exporters + +### 8. Exporter Registry + +```ruby +# lib/agent_manifest/exporter_registry.rb +module AgentManifest + class ExporterRegistry + class << self + def exporters + @exporters ||= {} + end + + def register(format, exporter_class) + exporters[format.to_sym] = exporter_class + end + + def exporter_for(format) + exporters[format.to_sym] || raise(UnknownFormatError, "No exporter for format: #{format}") + end + + def export(manifest, format:, path: nil) + content = exporter_for(format).export(manifest) + if path + File.write(path, content) + path + else + content + end + end + end + end +end +``` + +### 9. AgentMd Exporter + +```ruby +# lib/agent_manifest/exporters/agent_md_exporter.rb +module AgentManifest + module Exporters + class AgentMdExporter + class << self + def export(manifest) + frontmatter = build_frontmatter(manifest) + body = manifest.template || build_default_body(manifest) + + <<~OUTPUT + --- + #{frontmatter.to_yaml.lines[1..].join}--- + + #{body} + OUTPUT + end + + private + + def build_frontmatter(manifest) + fm = {} + + # Meta + fm['name'] = manifest.name + fm['version'] = manifest.version if manifest.version != '1.0.0' + fm['description'] = manifest.description if manifest.description + fm['author'] = manifest.author if manifest.author + fm['license'] = manifest.license if manifest.license + fm['repository'] = manifest.repository if manifest.repository + fm['tags'] = manifest.tags if manifest.tags.any? + fm['extends'] = manifest.extends if manifest.extends + + # Model + fm['model'] = manifest.model if manifest.model + fm['config'] = manifest.config if manifest.config&.any? + + # Schemas + if manifest.input_schema + fm['input'] = { 'schema' => manifest.input_schema.to_picoschema } + end + if manifest.output_schema + fm['output'] = manifest.output_schema + end + + # Tools + if manifest.tools.any? + fm['tools'] = manifest.tools.map(&:to_h) + end + + # Resources + if manifest.resources.any? + fm['resources'] = manifest.resources.map(&:to_h) + end + + # Extensions + manifest.extensions.each do |framework, config| + fm[framework.to_s] = config if config.any? + end + + fm + end + + def build_default_body(manifest) + <<~BODY + # #{manifest.name.titleize} + + #{manifest.description} + + ## Instructions + + #{manifest.instructions} + BODY + end + end + end + + ExporterRegistry.register(:agent_md, AgentMdExporter) + end +end +``` + +### 10. Dotprompt Exporter + +```ruby +# lib/agent_manifest/exporters/dotprompt_exporter.rb +module AgentManifest + module Exporters + class DotpromptExporter + class << self + def export(manifest) + frontmatter = build_frontmatter(manifest) + body = convert_template(manifest.template) + + <<~OUTPUT + --- + #{frontmatter.to_yaml.lines[1..].join}--- + + #{body} + OUTPUT + end + + private + + def build_frontmatter(manifest) + fm = {} + + fm['model'] = manifest.model if manifest.model + + # Config uses Dotprompt field names + if manifest.config + fm['temperature'] = manifest.config[:temperature] if manifest.config[:temperature] + fm['maxOutputTokens'] = manifest.config[:max_tokens] if manifest.config[:max_tokens] + fm['topP'] = manifest.config[:top_p] if manifest.config[:top_p] + fm['topK'] = manifest.config[:top_k] if manifest.config[:top_k] + fm['stopSequences'] = manifest.config[:stop] if manifest.config[:stop] + end + + # Schemas + if manifest.input_schema + fm['input'] = { 'schema' => manifest.input_schema.to_picoschema } + end + if manifest.output_schema + fm['output'] = manifest.output_schema + end + + # Tools + if manifest.tools.any? + fm['tools'] = manifest.tools.map { |t| t.name } + end + + fm + end + + def convert_template(template) + # Template syntax is compatible (both use Handlebars) + # But may need to strip markdown sections + return '' unless template + + # Remove markdown headers that aren't part of the prompt + template + .gsub(/^#\s+.*\n/, '') + .gsub(/^##\s+(Instructions|Guidelines|Context)\s*\n/, '') + .strip + end + end + end + + ExporterRegistry.register(:dotprompt, DotpromptExporter) + end +end +``` + +### 11. CrewAI Exporter + +```ruby +# lib/agent_manifest/exporters/crewai_exporter.rb +module AgentManifest + module Exporters + class CrewAIExporter + class << self + def export(manifest) + agent_key = manifest.name.underscore + + agent_data = build_agent_data(manifest) + + { agent_key => agent_data }.to_yaml + end + + def export_multiple(manifests) + agents = {} + manifests.each do |manifest| + agent_key = manifest.name.underscore + agents[agent_key] = build_agent_data(manifest) + end + agents.to_yaml + end + + private + + def build_agent_data(manifest) + data = {} + + # Use CrewAI extension if available + crewai = manifest.crewai_config + + data['role'] = crewai[:role] || extract_role(manifest) + data['goal'] = crewai[:goal] || manifest.description + data['backstory'] = crewai[:backstory] || manifest.instructions + + # Model + data['llm'] = convert_model(manifest.model) if manifest.model + + # Tools + if manifest.tools.any? + data['tools'] = manifest.tools.map { |t| t.ref || t.name } + end + + # Other CrewAI options + data['verbose'] = crewai[:verbose] if crewai[:verbose] + data['allow_delegation'] = crewai[:allow_delegation] if crewai.key?(:allow_delegation) + data['max_iter'] = crewai[:max_iter] if crewai[:max_iter] + data['max_rpm'] = crewai[:max_rpm] if crewai[:max_rpm] + + data.compact + end + + def extract_role(manifest) + # Try to extract role from instructions or name + manifest.name.titleize.gsub('-', ' ') + end + + def convert_model(model) + # CrewAI uses different model format + # e.g., "anthropic/claude-sonnet-4-20250514" -> "claude-sonnet-4-20250514" or provider-specific + model.split('/').last + end + end + end + + ExporterRegistry.register(:crewai, CrewAIExporter) + end +end +``` + +## Picoschema Parser + +```ruby +# lib/agent_manifest/picoschema_parser.rb +module AgentManifest + class PicoschemaParser + SCALAR_TYPES = %w[string integer number boolean any].freeze + + class << self + # Convert Picoschema to JSON Schema + def to_json_schema(picoschema) + return nil unless picoschema + + if picoschema.is_a?(Hash) + parse_object(picoschema) + else + { "type" => "object" } + end + end + + # Convert JSON Schema to Picoschema + def from_json_schema(json_schema) + return nil unless json_schema + + if json_schema['type'] == 'object' && json_schema['properties'] + convert_properties(json_schema['properties'], json_schema['required'] || []) + else + json_schema + end + end + + private + + def parse_object(schema_hash) + properties = {} + required = [] + + schema_hash.each do |key, value| + field_name, optional = parse_field_name(key) + field_schema = parse_field(value) + + properties[field_name] = field_schema + required << field_name unless optional + end + + result = { "type" => "object", "properties" => properties } + result["required"] = required if required.any? + result + end + + def parse_field_name(key) + if key.end_with?('?') + [key.chomp('?'), true] # optional + else + [key, false] # required + end + end + + def parse_field(value) + case value + when String + parse_type_string(value) + when Hash + # Nested object + parse_object(value) + when Array + # Array type + if value.first.is_a?(Hash) + { "type" => "array", "items" => parse_object(value.first) } + else + { "type" => "array", "items" => parse_type_string(value.first.to_s) } + end + else + { "type" => "string" } + end + end + + def parse_type_string(type_str) + # Parse: "type, description" or "type(enum1, enum2), description" + parts = type_str.split(',', 2) + type_part = parts[0].strip + description = parts[1]&.strip + + result = {} + + # Check for enum: string(a, b, c) + if type_part =~ /^(\w+)\(([^)]+)\)$/ + base_type = $1 + enum_values = $2.split(',').map(&:strip) + result["type"] = base_type + result["enum"] = enum_values + elsif SCALAR_TYPES.include?(type_part) + result["type"] = type_part + elsif type_part.start_with?('[') && type_part.end_with?(']') + inner = type_part[1..-2] + result["type"] = "array" + result["items"] = { "type" => inner } + else + result["type"] = type_part + end + + result["description"] = description if description + + result + end + + def convert_properties(properties, required_fields) + result = {} + + properties.each do |name, schema| + optional = !required_fields.include?(name) + key = optional ? "#{name}?" : name + result[key] = convert_schema_to_pico(schema) + end + + result + end + + def convert_schema_to_pico(schema) + type = schema['type'] + desc = schema['description'] + + pico = type.to_s + if schema['enum'] + pico = "#{type}(#{schema['enum'].join(', ')})" + end + if desc + pico = "#{pico}, #{desc}" + end + + pico + end + end + end +end +``` + +## Main Entry Point + +```ruby +# lib/agent_manifest.rb +require_relative 'agent_manifest/manifest' +require_relative 'agent_manifest/parser_registry' +require_relative 'agent_manifest/exporter_registry' +require_relative 'agent_manifest/picoschema_parser' + +# Load all parsers +require_relative 'agent_manifest/parsers/base_parser' +require_relative 'agent_manifest/parsers/agent_md_parser' +require_relative 'agent_manifest/parsers/dotprompt_parser' +require_relative 'agent_manifest/parsers/crewai_parser' +require_relative 'agent_manifest/parsers/github_prompt_parser' + +# Load all exporters +require_relative 'agent_manifest/exporters/agent_md_exporter' +require_relative 'agent_manifest/exporters/dotprompt_exporter' +require_relative 'agent_manifest/exporters/crewai_exporter' + +module AgentManifest + class << self + # Parse any supported format + def parse(path, format: nil) + ParserRegistry.parse(path, format: format) + end + + # Parse from string + def parse_string(content, format:) + ParserRegistry.parse_string(content, format: format) + end + + # Export to any supported format + def export(manifest, format:, path: nil) + ExporterRegistry.export(manifest, format: format, path: path) + end + + # Convert between formats + def convert(input_path, output_format:, output_path: nil) + manifest = parse(input_path) + export(manifest, format: output_format, path: output_path) + end + + # Load and instantiate as ActiveAgent + def load_agent(path, format: nil) + manifest = parse(path, format: format) + AgentBuilder.build(manifest) + end + end +end +``` + +## ActiveAgent Integration + +```ruby +# lib/agent_manifest/agent_builder.rb +module AgentManifest + class AgentBuilder + class << self + def build(manifest) + # Create a dynamic agent class from manifest + agent_class = Class.new(base_class(manifest)) do + # Include concerns based on manifest + end + + # Configure from manifest + configure_agent(agent_class, manifest) + + agent_class + end + + private + + def base_class(manifest) + config = manifest.activeagent_config + parent = config[:parent_class] || 'ApplicationAgent' + parent.constantize + rescue NameError + ActiveAgent::Base + end + + def configure_agent(klass, manifest) + config = manifest.activeagent_config + + # Set model + if manifest.model + provider, model = manifest.model.split('/') + klass.generate_with provider.to_sym, model: model + end + + # Include concerns + concerns = config[:concerns] || [] + concerns.each do |concern| + apply_concern(klass, concern) + end + + # Define tools + manifest.tools.each do |tool| + define_tool(klass, tool) unless tool.reference? + end + + # Set instructions + klass.define_method(:system_instructions) do + manifest.instructions + end + end + + def apply_concern(klass, concern) + case concern + when Hash + concern.each do |name, options| + case name.to_sym + when :has_context + klass.include(SolidAgent::HasContext) + klass.has_context(**options.symbolize_keys) + when :has_tools + klass.include(SolidAgent::HasTools) + klass.has_tools(*options) if options.is_a?(Array) + when :streams_tool_updates + klass.include(SolidAgent::StreamsToolUpdates) + end + end + when String, Symbol + klass.include("SolidAgent::#{concern.to_s.camelize}".constantize) + end + end + + def define_tool(klass, tool) + klass.tool tool.name.to_sym do + description tool.description + if tool.input_schema && tool.input_schema['properties'] + tool.input_schema['properties'].each do |param_name, param_schema| + required = tool.input_schema['required']&.include?(param_name) + parameter param_name.to_sym, + type: param_schema['type']&.to_sym, + required: required, + description: param_schema['description'] + end + end + end + end + end + end +end +``` + +## CLI Commands + +```ruby +# lib/agent_manifest/cli.rb +module AgentManifest + class CLI < Thor + desc "parse PATH", "Parse an agent file and display info" + option :format, type: :string, desc: "Force input format" + option :json, type: :boolean, desc: "Output as JSON" + def parse(path) + manifest = AgentManifest.parse(path, format: options[:format]&.to_sym) + + if options[:json] + puts JSON.pretty_generate(manifest.to_h) + else + display_manifest(manifest) + end + end + + desc "convert INPUT OUTPUT", "Convert between agent formats" + option :from, type: :string, desc: "Input format" + option :to, type: :string, required: true, desc: "Output format" + def convert(input, output) + manifest = AgentManifest.parse(input, format: options[:from]&.to_sym) + AgentManifest.export(manifest, format: options[:to].to_sym, path: output) + say "Converted #{input} -> #{output}", :green + end + + desc "validate PATH", "Validate an agent file" + def validate(path) + manifest = AgentManifest.parse(path) + errors = Validator.validate(manifest) + + if errors.empty? + say "✓ Valid agent definition", :green + else + say "✗ Validation errors:", :red + errors.each { |e| say " - #{e}", :red } + exit 1 + end + end + + desc "init NAME", "Create a new agent file" + option :format, type: :string, default: "agent_md", desc: "Output format" + option :template, type: :string, desc: "Template to use" + def init(name) + # Generate from template + end + + private + + def display_manifest(manifest) + say "Agent: #{manifest.name}", :green + say "Version: #{manifest.version}" + say "Model: #{manifest.model}" if manifest.model + say "Tools: #{manifest.tools.map(&:name).join(', ')}" if manifest.tools.any? + say "Description: #{manifest.description}" if manifest.description + end + end +end +``` + +## Usage Examples + +```ruby +# Parse any format +manifest = AgentManifest.parse("research-assistant.agent.md") +manifest = AgentManifest.parse("agent.prompt") # Dotprompt +manifest = AgentManifest.parse("agents.yaml") # CrewAI + +# Convert formats +AgentManifest.convert("agent.prompt", output_format: :agent_md, output_path: "agent.agent.md") +AgentManifest.convert("agents.yaml", output_format: :agent_md, output_path: "agent.agent.md") + +# Export to different format +content = AgentManifest.export(manifest, format: :crewai) +AgentManifest.export(manifest, format: :dotprompt, path: "output.prompt") + +# Load and use as ActiveAgent +agent_class = AgentManifest.load_agent("research-assistant.agent.md") +agent = agent_class.new(params: { query: "What is AI?" }) +result = agent.research.generate_now + +# Parse from string +content = File.read("agent.agent.md") +manifest = AgentManifest.parse_string(content, format: :agent_md) +``` + +## Supported Formats Summary + +| Format | Extension | Parse | Export | Notes | +|--------|-----------|-------|--------|-------| +| AgentMd | `.agent.md` | ✅ | ✅ | Native format | +| Dotprompt | `.prompt` | ✅ | ✅ | Google/Firebase | +| CrewAI | `agents.yaml` | ✅ | ✅ | Python framework | +| GitHub Prompt | `.prompt.md` | ✅ | ⚠️ | Copilot prompts | +| LangChain | `*.py` | 🚧 | 🚧 | Code-based | +| MCP Tool | `.tool.json` | ✅ | ✅ | Tool definitions only | + +✅ Full support | ⚠️ Partial | 🚧 Planned diff --git a/docs/registry-api.md b/docs/registry-api.md new file mode 100644 index 0000000..dcc9f4d --- /dev/null +++ b/docs/registry-api.md @@ -0,0 +1,882 @@ +# ActiveAgents Registry API + +**Base URL:** `https://api.activeagents.ai/v1` + +## Overview + +The ActiveAgents Registry is a package repository for shareable AI agents. It follows patterns from npm, RubyGems, and PyPI while adding AI-specific features like model compatibility tracking, sandboxed testing, and framework interoperability. + +## Authentication + +```http +Authorization: Bearer +``` + +Tokens are obtained via: +- `activeagent login` CLI command +- OAuth flow on activeagents.ai +- API token generation in dashboard + +## Core Resources + +### Agents (Packages) + +An agent package contains: +- `agent.md` - Main definition file +- Tools, prompts, tests, examples +- Metadata (versions, dependencies, compatibility) + +### Organizations + +Scoped namespaces for agents (e.g., `@activeagents/research-assistant`) + +### Users + +Individual accounts that can publish and star agents + +--- + +## API Endpoints + +### Search & Discovery + +#### Search Agents + +```http +GET /agents +``` + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `q` | string | Full-text search query | +| `tags` | string | Comma-separated tags | +| `framework` | string | Filter by framework compatibility | +| `model` | string | Filter by model compatibility | +| `author` | string | Filter by author/org | +| `sort` | string | `downloads`, `stars`, `updated`, `created` | +| `order` | string | `asc`, `desc` | +| `page` | integer | Page number (default: 1) | +| `per_page` | integer | Results per page (default: 20, max: 100) | + +**Example:** + +```http +GET /agents?q=research&tags=web,rag&framework=activeagent&sort=downloads +``` + +**Response:** + +```json +{ + "data": [ + { + "name": "@activeagents/research-assistant", + "version": "1.2.0", + "description": "An agent that researches topics and synthesizes findings", + "author": { + "name": "ActiveAgents", + "username": "activeagents" + }, + "downloads": { + "total": 15420, + "weekly": 342 + }, + "stars": 89, + "license": "MIT", + "tags": ["research", "web", "summarization", "rag"], + "compatibility": { + "frameworks": ["activeagent", "crewai", "langchain"], + "models": ["anthropic/*", "openai/*"] + }, + "updated_at": "2025-01-20T14:22:00Z" + } + ], + "meta": { + "total": 156, + "page": 1, + "per_page": 20, + "total_pages": 8 + } +} +``` + +#### Get Agent Details + +```http +GET /agents/:scope/:name +GET /agents/:name +``` + +**Example:** + +```http +GET /agents/@activeagents/research-assistant +``` + +**Response:** + +```json +{ + "name": "@activeagents/research-assistant", + "version": "1.2.0", + "description": "An agent that researches topics and synthesizes findings", + "readme": "# Research Assistant\n\nA powerful agent for...", + "author": { + "name": "ActiveAgents", + "username": "activeagents", + "avatar_url": "https://cdn.activeagents.ai/avatars/activeagents.png" + }, + "repository": { + "type": "git", + "url": "https://github.com/activeagents/research-assistant" + }, + "license": "MIT", + "tags": ["research", "web", "summarization", "rag"], + "keywords": ["ai", "research", "web-scraping"], + + "downloads": { + "total": 15420, + "weekly": 342, + "daily": 48 + }, + "stars": 89, + "forks": 12, + + "compatibility": { + "frameworks": { + "activeagent": ">=0.5.0", + "crewai": ">=0.30.0", + "langchain": ">=0.1.0" + }, + "models": { + "anthropic": ["claude-sonnet-4-20250514", "claude-3-5-haiku-*"], + "openai": ["gpt-4o", "gpt-4o-mini"], + "google": ["gemini-2.0-*"] + } + }, + + "dependencies": { + "@activeagents/web-tools": "^1.0.0", + "@activeagents/summarization": "^2.1.0" + }, + + "versions": { + "latest": "1.2.0", + "stable": "1.2.0", + "beta": "1.3.0-beta.1" + }, + + "files": { + "agent.md": { + "size": 4521, + "checksum": "sha256:abc123..." + }, + "tools/search.tool.json": { + "size": 892, + "checksum": "sha256:def456..." + } + }, + + "maintainers": [ + { + "username": "activeagents", + "role": "owner" + } + ], + + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-20T14:22:00Z" +} +``` + +#### Get Agent Version + +```http +GET /agents/:scope/:name/versions/:version +``` + +**Example:** + +```http +GET /agents/@activeagents/research-assistant/versions/1.2.0 +``` + +**Response:** + +```json +{ + "name": "@activeagents/research-assistant", + "version": "1.2.0", + "description": "An agent that researches topics and synthesizes findings", + + "manifest": { + "name": "research-assistant", + "model": "anthropic/claude-sonnet-4-20250514", + "tools": ["web_search", "navigate", "summarize"], + "input": { + "schema": { + "query": "string, the research question", + "depth?": "string(shallow, moderate, deep)" + } + } + }, + + "changelog": "## 1.2.0\n\n- Added deep research mode\n- Improved source citation", + + "files": [ + { + "path": "agent.md", + "size": 4521, + "checksum": "sha256:abc123...", + "download_url": "https://cdn.activeagents.ai/packages/@activeagents/research-assistant/1.2.0/agent.md" + }, + { + "path": "tools/search.tool.json", + "size": 892, + "checksum": "sha256:def456...", + "download_url": "https://cdn.activeagents.ai/packages/@activeagents/research-assistant/1.2.0/tools/search.tool.json" + } + ], + + "dependencies": { + "@activeagents/web-tools": "^1.0.0" + }, + + "published_at": "2025-01-20T14:22:00Z", + "published_by": { + "username": "activeagents" + } +} +``` + +#### List Versions + +```http +GET /agents/:scope/:name/versions +``` + +**Response:** + +```json +{ + "data": [ + { + "version": "1.2.0", + "tag": "latest", + "published_at": "2025-01-20T14:22:00Z", + "downloads": 342 + }, + { + "version": "1.3.0-beta.1", + "tag": "beta", + "published_at": "2025-01-22T09:15:00Z", + "downloads": 28 + }, + { + "version": "1.1.0", + "published_at": "2025-01-10T11:30:00Z", + "downloads": 8420 + } + ] +} +``` + +### Download & Installation + +#### Download Package + +```http +GET /agents/:scope/:name/-/:name-:version.tgz +``` + +Returns the packaged agent as a tarball. + +**Example:** + +```http +GET /agents/@activeagents/research-assistant/-/research-assistant-1.2.0.tgz +``` + +#### Download Single File + +```http +GET /agents/:scope/:name/files/:path +GET /agents/:scope/:name/versions/:version/files/:path +``` + +**Example:** + +```http +GET /agents/@activeagents/research-assistant/files/agent.md +GET /agents/@activeagents/research-assistant/versions/1.2.0/files/tools/search.tool.json +``` + +### Publishing + +#### Publish New Version + +```http +PUT /agents/:scope/:name +Authorization: Bearer +Content-Type: application/gzip +``` + +**Request Body:** gzipped tarball of the agent package + +**Headers:** + +| Header | Description | +|--------|-------------| +| `x-agent-tag` | Distribution tag (`latest`, `beta`, etc.) | +| `x-agent-access` | Access level (`public`, `restricted`) | + +**Response:** + +```json +{ + "name": "@activeagents/research-assistant", + "version": "1.2.0", + "tag": "latest", + "published_at": "2025-01-20T14:22:00Z", + "files": [ + "agent.md", + "tools/search.tool.json" + ], + "size": 12480, + "checksum": "sha256:abc123..." +} +``` + +#### Deprecate Version + +```http +POST /agents/:scope/:name/versions/:version/deprecate +Authorization: Bearer +``` + +**Request Body:** + +```json +{ + "message": "This version has a critical bug. Please upgrade to 1.2.1" +} +``` + +#### Unpublish Version + +```http +DELETE /agents/:scope/:name/versions/:version +Authorization: Bearer +``` + +Only allowed within 72 hours of publishing and if no dependents. + +### User Actions + +#### Star Agent + +```http +PUT /agents/:scope/:name/star +Authorization: Bearer +``` + +#### Unstar Agent + +```http +DELETE /agents/:scope/:name/star +Authorization: Bearer +``` + +#### Fork Agent + +```http +POST /agents/:scope/:name/fork +Authorization: Bearer +``` + +**Request Body:** + +```json +{ + "name": "my-research-assistant", + "scope": "@myorg" +} +``` + +**Response:** + +```json +{ + "name": "@myorg/my-research-assistant", + "forked_from": "@activeagents/research-assistant", + "version": "1.0.0", + "created_at": "2025-01-20T15:00:00Z" +} +``` + +### Testing & Playground + +#### Run Agent in Sandbox + +```http +POST /agents/:scope/:name/run +Authorization: Bearer +Content-Type: application/json +``` + +**Request Body:** + +```json +{ + "version": "1.2.0", + "input": { + "query": "What are the benefits of meditation?", + "depth": "shallow" + }, + "options": { + "model_override": "anthropic/claude-3-5-haiku-latest", + "timeout": 30000, + "trace": true + } +} +``` + +**Response:** + +```json +{ + "run_id": "run_abc123", + "status": "completed", + "output": { + "summary": "Meditation offers numerous benefits...", + "sources": [ + { + "url": "https://example.com/meditation-study", + "title": "Scientific Study on Meditation", + "relevance": 0.92 + } + ], + "confidence": 0.85 + }, + "trace": { + "tools_called": [ + { + "name": "web_search", + "input": {"query": "meditation benefits scientific studies"}, + "output": {"results": [...]}, + "duration_ms": 1250 + } + ], + "model_calls": [ + { + "model": "anthropic/claude-3-5-haiku-latest", + "input_tokens": 1420, + "output_tokens": 890, + "duration_ms": 2100 + } + ] + }, + "usage": { + "total_tokens": 2310, + "estimated_cost": 0.0023, + "duration_ms": 4500 + } +} +``` + +#### Get Run Status + +```http +GET /runs/:run_id +Authorization: Bearer +``` + +For long-running agents, poll this endpoint or use webhooks. + +#### Stream Run Output + +```http +GET /runs/:run_id/stream +Authorization: Bearer +Accept: text/event-stream +``` + +Returns Server-Sent Events for real-time streaming: + +``` +event: tool_start +data: {"tool": "web_search", "input": {"query": "meditation benefits"}} + +event: tool_complete +data: {"tool": "web_search", "duration_ms": 1250} + +event: chunk +data: {"content": "Meditation offers"} + +event: chunk +data: {"content": " numerous benefits..."} + +event: complete +data: {"run_id": "run_abc123", "status": "completed"} +``` + +### Organizations + +#### Create Organization + +```http +POST /orgs +Authorization: Bearer +``` + +**Request Body:** + +```json +{ + "name": "myorg", + "display_name": "My Organization", + "email": "contact@myorg.com" +} +``` + +#### List Organization Agents + +```http +GET /orgs/:org/agents +``` + +#### Add Organization Member + +```http +PUT /orgs/:org/members/:username +Authorization: Bearer +``` + +**Request Body:** + +```json +{ + "role": "developer" +} +``` + +Roles: `owner`, `admin`, `developer`, `readonly` + +### Webhooks + +#### Register Webhook + +```http +POST /agents/:scope/:name/hooks +Authorization: Bearer +``` + +**Request Body:** + +```json +{ + "url": "https://myapp.com/webhooks/activeagents", + "events": ["publish", "star", "fork", "run"], + "secret": "my-webhook-secret" +} +``` + +#### Webhook Events + +| Event | Description | +|-------|-------------| +| `publish` | New version published | +| `deprecate` | Version deprecated | +| `star` | Agent starred | +| `unstar` | Agent unstarred | +| `fork` | Agent forked | +| `run` | Agent run in sandbox | +| `download` | Agent downloaded | + +**Webhook Payload:** + +```json +{ + "event": "publish", + "timestamp": "2025-01-20T14:22:00Z", + "agent": { + "name": "@activeagents/research-assistant", + "version": "1.2.0" + }, + "actor": { + "username": "activeagents" + } +} +``` + +--- + +## Rate Limits + +| Endpoint Type | Authenticated | Anonymous | +|---------------|---------------|-----------| +| Search/Read | 1000/hour | 100/hour | +| Download | 500/hour | 50/hour | +| Publish | 100/hour | N/A | +| Run (Sandbox) | 50/hour | 5/hour | + +Rate limit headers: + +```http +X-RateLimit-Limit: 1000 +X-RateLimit-Remaining: 999 +X-RateLimit-Reset: 1705764000 +``` + +--- + +## Error Responses + +```json +{ + "error": { + "code": "NOT_FOUND", + "message": "Agent not found: @activeagents/nonexistent", + "details": { + "name": "@activeagents/nonexistent" + } + } +} +``` + +### Error Codes + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `NOT_FOUND` | 404 | Resource not found | +| `UNAUTHORIZED` | 401 | Missing or invalid auth | +| `FORBIDDEN` | 403 | Permission denied | +| `CONFLICT` | 409 | Version already exists | +| `VALIDATION_ERROR` | 422 | Invalid input | +| `RATE_LIMITED` | 429 | Too many requests | +| `INTERNAL_ERROR` | 500 | Server error | + +--- + +## SDK Usage + +### Ruby (activeagent gem) + +```ruby +require 'activeagent/registry' + +# Configure client +ActiveAgent::Registry.configure do |config| + config.api_token = ENV['ACTIVEAGENT_TOKEN'] +end + +# Search agents +agents = ActiveAgent::Registry.search( + q: 'research', + tags: ['web', 'rag'], + framework: 'activeagent' +) + +# Get agent details +agent = ActiveAgent::Registry.get('@activeagents/research-assistant') +puts agent.version # => "1.2.0" +puts agent.model # => "anthropic/claude-sonnet-4-20250514" + +# Download and install +ActiveAgent::Registry.install('@activeagents/research-assistant') +# Installs to: vendor/agents/@activeagents/research-assistant/ + +# Load and use +agent_class = ActiveAgent::Registry.load('@activeagents/research-assistant') +agent = agent_class.new(params: { query: 'What is AI?' }) +result = agent.research.generate_now + +# Publish +ActiveAgent::Registry.publish( + path: './my-agent', + tag: 'latest' +) +``` + +### JavaScript/TypeScript + +```typescript +import { AgentRegistry } from '@activeagents/sdk'; + +const registry = new AgentRegistry({ + token: process.env.ACTIVEAGENT_TOKEN +}); + +// Search +const agents = await registry.search({ + q: 'research', + tags: ['web', 'rag'] +}); + +// Download manifest +const manifest = await registry.getManifest('@activeagents/research-assistant'); + +// Run in sandbox +const result = await registry.run('@activeagents/research-assistant', { + input: { query: 'What is AI?' } +}); +``` + +### Python + +```python +from activeagents import Registry + +registry = Registry(token=os.environ['ACTIVEAGENT_TOKEN']) + +# Search +agents = registry.search(q='research', tags=['web', 'rag']) + +# Download and convert to CrewAI +manifest = registry.get('@activeagents/research-assistant') +crewai_yaml = registry.export(manifest, format='crewai') + +# Run in sandbox +result = registry.run( + '@activeagents/research-assistant', + input={'query': 'What is AI?'} +) +``` + +--- + +## CLI Reference + +```bash +# Authentication +activeagent login # OAuth login flow +activeagent logout +activeagent whoami + +# Search & Discovery +activeagent search research # Search for agents +activeagent search --tags web,rag # Filter by tags +activeagent info @activeagents/research-assistant # Get details + +# Installation +activeagent add @activeagents/research-assistant +activeagent add @activeagents/research-assistant@1.2.0 +activeagent add @activeagents/research-assistant --save # Add to agent.json +activeagent remove @activeagents/research-assistant + +# Publishing +activeagent init my-agent # Create new agent +activeagent validate # Validate before publish +activeagent publish # Publish to registry +activeagent publish --tag beta # Publish with tag +activeagent deprecate 1.1.0 --message "Upgrade to 1.2.0" +activeagent unpublish 1.0.0 # Remove version (within 72h) + +# Testing +activeagent test # Run local tests +activeagent run @activeagents/research-assistant # Run in sandbox +activeagent run . --input '{"query": "test"}' # Run local agent + +# Organizations +activeagent org create myorg +activeagent org add-member myorg janedoe --role developer +activeagent org list-members myorg + +# Utilities +activeagent convert agent.prompt --to agent.md # Convert formats +activeagent export . --format crewai # Export to CrewAI +``` + +--- + +## Web Interface Features + +### Agent Page (activeagents.ai/agents/@activeagents/research-assistant) + +- **Overview** - Description, README, quick stats +- **Playground** - Interactive testing with real-time output +- **Versions** - Version history with changelogs +- **Dependencies** - Dependency graph +- **Dependents** - Who uses this agent +- **Files** - Browse package contents +- **Settings** - Maintainer settings (if owner) + +### Playground Features + +- **Input Editor** - JSON/Form input with schema validation +- **Model Selector** - Test with different models +- **Streaming Output** - Real-time response streaming +- **Tool Trace** - Visual tool execution timeline +- **Cost Estimator** - Token usage and cost tracking +- **Share** - Generate shareable playground links +- **Fork to Sandbox** - One-click fork to edit + +### Dashboard + +- **My Agents** - Published agents with stats +- **Starred** - Bookmarked agents +- **Recent Runs** - Playground history +- **Usage** - API usage and billing +- **Teams** - Organization management + +--- + +## Pricing Tiers + +### Free Tier +- Unlimited public agents +- 100 sandbox runs/month +- 10 MB storage per agent +- Community support + +### Pro ($29/month) +- Private agents +- 1,000 sandbox runs/month +- 100 MB storage per agent +- Priority support +- Custom domains + +### Team ($99/month) +- Everything in Pro +- 5,000 sandbox runs/month +- Organization management +- SSO/SAML +- Audit logs +- SLA + +### Enterprise (Custom) +- Unlimited runs +- Self-hosted option +- Custom integrations +- Dedicated support +- Custom contracts + +--- + +## Future Roadmap + +### Phase 1 (Current) +- Basic registry CRUD +- Package publishing +- Search and discovery +- CLI tools + +### Phase 2 +- Sandbox execution environment +- Interactive playground +- Model compatibility matrix +- Usage analytics + +### Phase 3 +- Agent marketplace (paid agents) +- Revenue sharing for authors +- Enterprise self-hosted +- CI/CD integrations + +### Phase 4 +- Agent composition (agents using agents) +- Automated testing infrastructure +- Performance benchmarks +- Security scanning diff --git a/lib/generators/solid_agent/agent/agent_generator.rb b/lib/generators/solid_agent/agent/agent_generator.rb index 2040cb9..1616f99 100644 --- a/lib/generators/solid_agent/agent/agent_generator.rb +++ b/lib/generators/solid_agent/agent/agent_generator.rb @@ -15,7 +15,7 @@ class AgentGenerator < Rails::Generators::NamedBase class_option :context_name, type: :string, default: nil, desc: "Custom context name (e.g., 'conversation', 'research_session')" - class_option :contextable, type: :string, default: nil, + class_option :contextual, type: :string, default: nil, desc: "Param key for auto-context (e.g., 'user', 'document')" class_option :tools, type: :boolean, default: false, @@ -34,7 +34,7 @@ def create_agent_file @parent_class = options[:parent] @include_context = options[:context] @context_name = options[:context_name] - @contextable = options[:contextable] + @contextual = options[:contextual] @include_tools = options[:tools] @include_streaming = options[:streaming] @actions = options[:actions] diff --git a/lib/generators/solid_agent/agent/templates/agent.rb.erb b/lib/generators/solid_agent/agent/templates/agent.rb.erb index 0e9bcf8..5121d62 100644 --- a/lib/generators/solid_agent/agent/templates/agent.rb.erb +++ b/lib/generators/solid_agent/agent/templates/agent.rb.erb @@ -19,11 +19,11 @@ class <%= class_name %>Agent < <%= @parent_class %> <%- if @include_context -%> # Enable database-backed context persistence - # Context is auto-created from params[:<%= @contextable || 'contextable' %>] + # Context is auto-created from params[:<%= @contextual || 'contextual' %>] <%- if @context_name -%> - has_context :<%= @context_name %>, contextable: :<%= @contextable || 'contextable' %> + has_context :<%= @context_name %>, contextual: :<%= @contextual || 'contextual' %> <%- else -%> - has_context contextable: :<%= @contextable || 'contextable' %> + has_context contextual: :<%= @contextual || 'contextual' %> <%- end -%> <%- end -%> diff --git a/lib/generators/solid_agent/manifest/manifest_generator.rb b/lib/generators/solid_agent/manifest/manifest_generator.rb new file mode 100644 index 0000000..a4ecbd8 --- /dev/null +++ b/lib/generators/solid_agent/manifest/manifest_generator.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails/generators" + +module SolidAgent + module Generators + class ManifestGenerator < Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + desc "Generates a .agent.md manifest file for an agent" + + class_option :model, type: :string, default: "anthropic/claude-sonnet-4-20250514", + desc: "Model identifier (provider/model format)" + + class_option :tools, type: :array, default: [], + desc: "Tool names to include" + + class_option :template, type: :string, default: nil, + desc: "Use a preset template (research, assistant, reviewer, chat)" + + class_option :format, type: :string, default: "agent_md", + desc: "Output format (agent_md, dotprompt)" + + class_option :context, type: :string, default: nil, + desc: "Contextual param key for HasContext (e.g., user, document)" + + class_option :description, type: :string, default: nil, + desc: "Agent description" + + def create_manifest_file + @model = options[:model] + @tools = options[:tools] + @preset = options[:template] + @format = options[:format].to_sym + @contextual = options[:context] + @description = options[:description] || default_description + + # Apply preset template configuration + apply_preset if @preset + + case @format + when :agent_md + template "agent.md.erb", manifest_path(".agent.md") + when :dotprompt + template "prompt.erb", manifest_path(".prompt") + else + template "agent.md.erb", manifest_path(".agent.md") + end + end + + def show_next_steps + say "" + say "Manifest created successfully!", :green + say "" + say "File generated:" + say " #{manifest_path(extension_for_format)}" + say "" + say "Usage:", :yellow + say " # Parse the manifest" + say " manifest = SolidAgent::AgentManifest.parse(\"#{manifest_path(extension_for_format)}\")" + say "" + say " # Load as agent class" + say " klass = SolidAgent::AgentManifest.load_agent(\"#{manifest_path(extension_for_format)}\")" + say "" + say " # Validate the manifest" + say " SolidAgent::AgentManifest.validate!(\"#{manifest_path(extension_for_format)}\")" + say "" + + if @tools.any? + say "Tool stubs added. Implement the tool methods in your agent class:", :yellow + @tools.each do |tool| + say " def #{tool}(args)" + say " # Implement #{tool} logic" + say " end" + end + say "" + end + end + + private + + def file_name + name.underscore + end + + def class_name + name.camelize + end + + def agent_class_name + "#{class_name}Agent" + end + + def agent_title + class_name.gsub(/([A-Z])/, ' \1').strip + end + + def manifest_path(extension) + "app/views/#{file_name}_agent/agent#{extension}" + end + + def extension_for_format + case @format + when :agent_md then ".agent.md" + when :dotprompt then ".prompt" + else ".agent.md" + end + end + + def default_description + "#{agent_title} agent" + end + + def apply_preset + case @preset.to_s + when "research" + @description ||= "Research and analyze topics thoroughly, providing well-sourced information" + @tools = %w[search fetch analyze] if @tools.empty? + @preset_instructions = research_instructions + when "assistant" + @description ||= "General-purpose assistant for answering questions and helping with tasks" + @preset_instructions = assistant_instructions + when "reviewer" + @description ||= "Review and provide feedback on content, code, or documents" + @tools = %w[read_file analyze_diff] if @tools.empty? + @preset_instructions = reviewer_instructions + when "chat" + @description ||= "Conversational agent for multi-turn dialogue" + @contextual ||= "user" + @preset_instructions = chat_instructions + end + end + + def research_instructions + <<~INSTRUCTIONS + You are a thorough research assistant. Your goal is to provide accurate, + well-sourced information on any topic. + + ## Guidelines + + - Always cite your sources when providing information + - Present multiple perspectives when topics are controversial + - Acknowledge uncertainty when information is incomplete + - Break down complex topics into understandable explanations + - Verify facts before presenting them + INSTRUCTIONS + end + + def assistant_instructions + <<~INSTRUCTIONS + You are a helpful assistant. Your goal is to assist users with their + questions and tasks effectively. + + ## Guidelines + + - Be concise but thorough in your responses + - Ask clarifying questions when requests are ambiguous + - Provide step-by-step guidance for complex tasks + - Offer alternatives when appropriate + INSTRUCTIONS + end + + def reviewer_instructions + <<~INSTRUCTIONS + You are a thorough reviewer. Your goal is to provide constructive feedback + that helps improve the quality of the work. + + ## Guidelines + + - Focus on both strengths and areas for improvement + - Be specific with your feedback + - Provide actionable suggestions + - Maintain a constructive and respectful tone + - Consider the context and goals of the work + INSTRUCTIONS + end + + def chat_instructions + <<~INSTRUCTIONS + You are a conversational assistant. Engage in helpful, natural dialogue + while maintaining context from previous messages. + + ## Guidelines + + - Remember context from earlier in the conversation + - Ask follow-up questions to better understand the user's needs + - Be friendly and approachable + - Know when to be concise vs. detailed based on the question + INSTRUCTIONS + end + + def default_instructions + @preset_instructions || <<~INSTRUCTIONS + You are the #{agent_title} agent. + + ## Instructions + + TODO - Add your agent's instructions here. + + ## Guidelines + + - Be helpful and accurate + - Follow the user's instructions carefully + - Ask for clarification when needed + INSTRUCTIONS + end + end + end +end diff --git a/lib/generators/solid_agent/manifest/templates/agent.md.erb b/lib/generators/solid_agent/manifest/templates/agent.md.erb new file mode 100644 index 0000000..ea14a33 --- /dev/null +++ b/lib/generators/solid_agent/manifest/templates/agent.md.erb @@ -0,0 +1,39 @@ +--- +name: <%= file_name %> +version: 1.0.0 +description: <%= @description %> +model: <%= @model %> +<% if @tools.any? -%> + +tools: +<% @tools.each do |tool| -%> + - name: <%= tool %> + description: "TODO - Describe what <%= tool %> does" + inputSchema: + type: object + properties: + query: + type: string + description: "Input for <%= tool %>" + required: + - query +<% end -%> +<% end -%> + +activeagent: + class_name: <%= agent_class_name %> + concerns: +<% if @contextual -%> + - has_context: + contextual: <%= @contextual %> +<% end -%> +<% if @tools.any? -%> + - has_tools: [<%= @tools.join(', ') %>] +<% end -%> +--- + +# <%= agent_title %> + +<%= @description %> + +<%= default_instructions %> diff --git a/lib/generators/solid_agent/manifest/templates/prompt.erb b/lib/generators/solid_agent/manifest/templates/prompt.erb new file mode 100644 index 0000000..5c6b5c7 --- /dev/null +++ b/lib/generators/solid_agent/manifest/templates/prompt.erb @@ -0,0 +1,13 @@ +--- +name: <%= file_name %> +model: <%= @model %> +description: <%= @description %> +<% if @tools.any? -%> +tools: +<% @tools.each do |tool| -%> + - <%= tool %> +<% end -%> +<% end -%> +--- + +<%= default_instructions %> diff --git a/lib/solid_agent.rb b/lib/solid_agent.rb index 6235044..c3d0056 100644 --- a/lib/solid_agent.rb +++ b/lib/solid_agent.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true require_relative "solid_agent/version" -require_relative "solid_agent/has_context" -require_relative "solid_agent/has_tools" -require_relative "solid_agent/streams_tool_updates" module SolidAgent class Error < StandardError; end @@ -22,6 +19,11 @@ def configure self.generation_class = "AgentGeneration" end +require_relative "solid_agent/has_context" +require_relative "solid_agent/has_tools" +require_relative "solid_agent/streams_tool_updates" +require_relative "solid_agent/agent_manifest" + # Load Rails integration if Rails is present if defined?(Rails::Engine) require_relative "solid_agent/engine" diff --git a/lib/solid_agent/agent_manifest.rb b/lib/solid_agent/agent_manifest.rb new file mode 100644 index 0000000..39531a5 --- /dev/null +++ b/lib/solid_agent/agent_manifest.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require_relative "agent_manifest/errors" +require_relative "agent_manifest/manifest" +require_relative "agent_manifest/tool" +require_relative "agent_manifest/resource" +require_relative "agent_manifest/input_schema" +require_relative "agent_manifest/picoschema" +require_relative "agent_manifest/parser_registry" +require_relative "agent_manifest/parsers/base_parser" +require_relative "agent_manifest/parsers/agent_md_parser" +require_relative "agent_manifest/parsers/dotprompt_parser" +require_relative "agent_manifest/parsers/crewai_parser" +require_relative "agent_manifest/parsers/github_prompt_parser" +require_relative "agent_manifest/exporter_registry" +require_relative "agent_manifest/exporters/base_exporter" +require_relative "agent_manifest/exporters/agent_md_exporter" +require_relative "agent_manifest/exporters/dotprompt_exporter" +require_relative "agent_manifest/exporters/crewai_exporter" +require_relative "agent_manifest/validator" +require_relative "agent_manifest/agent_builder" +require_relative "agent_manifest/registry/auth" +require_relative "agent_manifest/registry/client" + +module SolidAgent + # AgentManifest provides a unified interface for working with agent definition files. + # + # Supports multiple formats: + # - `.agent.md` - Native format with full feature support + # - `.prompt` - Google Dotprompt format + # - `agents.yaml` - CrewAI format + # - `.prompt.md` - GitHub Copilot format + # + # @example Parse a manifest file + # manifest = SolidAgent::AgentManifest.parse("research_assistant.agent.md") + # + # @example Parse with auto-detection + # manifest = SolidAgent::AgentManifest.parse("agents.yaml") + # + # @example Export to different format + # content = SolidAgent::AgentManifest.export(manifest, :dotprompt) + # + # @example Convert between formats + # SolidAgent::AgentManifest.convert("agent.yaml", :agent_md, "agent.agent.md") + # + # @example Load as agent class + # klass = SolidAgent::AgentManifest.load_agent("research.agent.md") + # agent = klass.new + # + # @example Validate a manifest + # errors = SolidAgent::AgentManifest.validate("agent.agent.md") + # + module AgentManifest + class << self + # Parse a manifest file + # + # @param path [String] Path to manifest file + # @param format [Symbol, nil] Force specific format (auto-detected if nil) + # @return [Manifest] Parsed manifest + # @raise [ParseError] if parsing fails + # @raise [UnknownFormatError] if format cannot be determined + def parse(path, format: nil) + ParserRegistry.parse(path, format: format) + end + + # Parse manifest content from a string + # + # @param content [String] Manifest content + # @param format [Symbol] Format of the content + # @return [Manifest] Parsed manifest + def parse_string(content, format:) + ParserRegistry.parse_string(content, format: format) + end + + # Export a manifest to a format string + # + # @param manifest [Manifest] Manifest to export + # @param format [Symbol] Target format (:agent_md, :dotprompt, :crewai) + # @param options [Hash] Format-specific options + # @return [String] Exported content + def export(manifest, format, **options) + ExporterRegistry.export(manifest, format, **options) + end + + # Export a manifest to a file + # + # @param manifest [Manifest] Manifest to export + # @param format [Symbol] Target format + # @param path [String] Output file path + # @param options [Hash] Format-specific options + # @return [String] The path written to + def export_to_file(manifest, format, path, **options) + ExporterRegistry.export_to_file(manifest, format, path, **options) + end + + # Convert a manifest file between formats + # + # @param input_path [String] Source file path + # @param output_format [Symbol] Target format + # @param output_path [String, nil] Output path (auto-generated if nil) + # @return [String] Output path + def convert(input_path, output_format, output_path = nil) + ExporterRegistry.convert(input_path, output_format, output_path) + end + + # Load an agent class from a manifest file + # + # @param path [String] Path to manifest file + # @param base_class [Class, nil] Base class to inherit from + # @param class_name [String, nil] Optional class name for registration + # @return [Class] The generated agent class + def load_agent(path, base_class: nil, class_name: nil) + manifest = parse(path) + AgentBuilder.build(manifest, base_class: base_class, class_name: class_name) + end + + # Load and instantiate an agent from a manifest file + # + # @param path [String] Path to manifest file + # @param params [Hash] Parameters to pass to the agent + # @param options [Hash] Options passed to build + # @return [Object] The instantiated agent + def load_agent_instance(path, params: {}, **options) + manifest = parse(path) + AgentBuilder.build_instance(manifest, params: params, **options) + end + + # Validate a manifest file or object + # + # @param path_or_manifest [String, Manifest] Path to file or Manifest object + # @param strict [Boolean] Enable strict validation + # @return [Array] Array of error messages (empty if valid) + def validate(path_or_manifest, strict: false) + manifest = path_or_manifest.is_a?(String) ? parse(path_or_manifest) : path_or_manifest + Validator.validate(manifest, strict: strict) + end + + # Check if a manifest is valid + # + # @param path_or_manifest [String, Manifest] Path to file or Manifest object + # @param strict [Boolean] Enable strict validation + # @return [Boolean] + def valid?(path_or_manifest, strict: false) + validate(path_or_manifest, strict: strict).empty? + end + + # Validate and raise if invalid + # + # @param path_or_manifest [String, Manifest] Path to file or Manifest object + # @param strict [Boolean] Enable strict validation + # @raise [ValidationError] if invalid + # @return [Manifest] The validated manifest + def validate!(path_or_manifest, strict: false) + manifest = path_or_manifest.is_a?(String) ? parse(path_or_manifest) : path_or_manifest + Validator.validate!(manifest, strict: strict) + end + + # List supported parser formats + # + # @return [Array] + def parser_formats + ParserRegistry.formats + end + + # List supported exporter formats + # + # @return [Array] + def exporter_formats + ExporterRegistry.formats + end + + # Detect the format of a file + # + # @param path [String] Path to file + # @return [Symbol] Detected format + # @raise [UnknownFormatError] if format cannot be determined + def detect_format(path) + ParserRegistry.detect_format(path) + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/agent_builder.rb b/lib/solid_agent/agent_manifest/agent_builder.rb new file mode 100644 index 0000000..360f227 --- /dev/null +++ b/lib/solid_agent/agent_manifest/agent_builder.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + # AgentBuilder generates ActiveAgent classes from Manifest definitions. + # + # Creates anonymous or named classes with the appropriate concerns included, + # tools defined, and system instructions configured. + # + # @example Build an agent class from a manifest + # manifest = AgentManifest.parse("research_assistant.agent.md") + # klass = AgentBuilder.build(manifest) + # agent = klass.new + # agent.research(query: "Ruby concurrency") + # + # @example Build with a custom base class + # klass = AgentBuilder.build(manifest, base_class: ApplicationAgent) + # + # @example Build with explicit naming + # klass = AgentBuilder.build(manifest, class_name: "CustomResearchAgent") + # CustomResearchAgent.new.call + # + class AgentBuilder + class << self + # Build an agent class from a manifest + # + # @param manifest [Manifest] The manifest to build from + # @param base_class [Class] Base class to inherit from + # @param class_name [String, nil] Optional class name for constant registration + # @param namespace [Module, nil] Optional namespace for constant registration + # @return [Class] The generated agent class + def build(manifest, base_class: nil, class_name: nil, namespace: nil) + # Determine base class + parent_class = determine_base_class(manifest, base_class) + + # Create the class + klass = Class.new(parent_class) + + # Configure the agent + configure_model(klass, manifest) + configure_concerns(klass, manifest) + configure_tools(klass, manifest) + configure_instructions(klass, manifest) + configure_metadata(klass, manifest) + + # Register as constant if requested + if class_name || manifest_class_name(manifest) + register_constant(klass, class_name || manifest_class_name(manifest), namespace) + end + + klass + end + + # Build and instantiate an agent from a manifest + # + # @param manifest [Manifest] The manifest to build from + # @param params [Hash] Parameters to pass to the agent + # @return [Object] The instantiated agent + def build_instance(manifest, params: {}, **options) + klass = build(manifest, **options) + klass.new(params) + end + + # Build from a file path + # + # @param path [String] Path to manifest file + # @param options [Hash] Options passed to build + # @return [Class] The generated agent class + def build_from_file(path, **options) + manifest = ParserRegistry.parse(path) + build(manifest, **options) + end + + private + + # Determine the base class for the agent + def determine_base_class(manifest, explicit_base) + return explicit_base if explicit_base + + # Check activeagent extension for parent class + aa_config = manifest.extensions&.dig(:activeagent) || {} + if aa_config[:parent_class] + aa_config[:parent_class].to_s.constantize + elsif defined?(ApplicationAgent) + ApplicationAgent + elsif defined?(ActiveAgent::Base) + ActiveAgent::Base + else + # Fallback to a basic class + Object + end + end + + # Configure model/provider settings + def configure_model(klass, manifest) + return unless manifest.model.present? + + provider, model_name = parse_model_identifier(manifest.model) + + # Set class-level configuration + klass.class_eval do + class_attribute :_manifest_model, default: nil + class_attribute :_manifest_provider, default: nil + class_attribute :_manifest_config, default: {} + end + + klass._manifest_model = model_name + klass._manifest_provider = provider + klass._manifest_config = manifest.config || {} + + # If the base class supports model configuration, use it + if klass.respond_to?(:model) + klass.model(model_name) + end + + if klass.respond_to?(:provider) && provider + klass.provider(provider) + end + end + + # Parse model identifier into provider and model name + def parse_model_identifier(model_string) + if model_string.include?("/") + parts = model_string.split("/", 2) + [parts[0], parts[1]] + else + [nil, model_string] + end + end + + # Configure concerns based on manifest extensions + def configure_concerns(klass, manifest) + aa_config = manifest.extensions&.dig(:activeagent) || {} + concerns = aa_config[:concerns] || [] + + concerns.each do |concern_config| + apply_concern(klass, concern_config) + end + + # Auto-include HasTools if manifest defines tools + if manifest.tools&.any? && !has_concern?(klass, SolidAgent::HasTools) + apply_concern(klass, "has_tools") + end + end + + # Apply a single concern to the class + def apply_concern(klass, concern_config) + case concern_config + when String, Symbol + concern_name = concern_config.to_s + options = {} + when Hash + concern_name = concern_config.keys.first.to_s + options = concern_config.values.first || {} + else + return + end + + concern_module = resolve_concern(concern_name) + return unless concern_module + + klass.include(concern_module) + + # Call the DSL method if it exists (e.g., has_context, has_tools) + if klass.respond_to?(concern_name) + if options.is_a?(Hash) && options.any? + klass.send(concern_name, **symbolize_keys(options)) + elsif options.is_a?(Array) + klass.send(concern_name, *options) + else + klass.send(concern_name) + end + end + end + + # Resolve a concern name to a module + def resolve_concern(name) + case name.to_s + when "has_context" + SolidAgent::HasContext + when "has_tools" + SolidAgent::HasTools + when "streams_tool_updates" + SolidAgent::StreamsToolUpdates + else + # Try to constantize + begin + name.to_s.camelize.constantize + rescue NameError + nil + end + end + end + + # Check if a class already includes a concern + def has_concern?(klass, concern) + klass.included_modules.include?(concern) + end + + # Configure tools from manifest + def configure_tools(klass, manifest) + return unless manifest.tools&.any? + + manifest.tools.each do |tool| + define_tool(klass, tool) unless tool.reference? + end + + # Store tool references for later resolution + tool_refs = manifest.tools.select(&:reference?).map(&:ref) + if tool_refs.any? + klass.class_attribute :_manifest_tool_refs, default: [] + klass._manifest_tool_refs = tool_refs + end + end + + # Define a tool on the class + def define_tool(klass, tool) + return unless klass.respond_to?(:tool) + + tool_name = tool.name.to_sym + tool_desc = tool.description + tool_schema = tool.input_schema || {} + + klass.tool(tool_name) do + description tool_desc if tool_desc + + # Define parameters from schema + properties = tool_schema["properties"] || tool_schema[:properties] || {} + required = tool_schema["required"] || tool_schema[:required] || [] + + properties.each do |param_name, param_schema| + param_opts = { + type: param_schema["type"] || param_schema[:type] || "string", + required: required.include?(param_name.to_s), + description: param_schema["description"] || param_schema[:description] + } + param_opts[:enum] = param_schema["enum"] || param_schema[:enum] if param_schema["enum"] || param_schema[:enum] + param_opts[:default] = param_schema["default"] || param_schema[:default] if param_schema.key?("default") || param_schema.key?(:default) + + parameter param_name.to_sym, **param_opts.compact + end + end + + # Define a placeholder method if it doesn't exist + unless klass.instance_methods.include?(tool_name) + klass.define_method(tool_name) do |**args| + raise NotImplementedError, "Tool method '#{tool_name}' must be implemented" + end + end + end + + # Configure system instructions + def configure_instructions(klass, manifest) + return unless manifest.instructions.present? || manifest.template.present? + + instructions = manifest.instructions || manifest.template + + klass.class_attribute :_manifest_instructions, default: nil + klass._manifest_instructions = instructions + + # If the base class supports system instructions, set them + if klass.respond_to?(:system_instructions) + klass.system_instructions(instructions) + end + + # Store template for rendering + if manifest.template.present? + klass.class_attribute :_manifest_template, default: nil + klass._manifest_template = manifest.template + end + end + + # Configure metadata + def configure_metadata(klass, manifest) + klass.class_attribute :_manifest, default: nil + klass._manifest = manifest + + klass.class_attribute :_manifest_name, default: nil + klass._manifest_name = manifest.name + + klass.class_attribute :_manifest_version, default: nil + klass._manifest_version = manifest.version + + klass.class_attribute :_manifest_description, default: nil + klass._manifest_description = manifest.description + end + + # Get class name from manifest + def manifest_class_name(manifest) + aa_config = manifest.extensions&.dig(:activeagent) || {} + return aa_config[:class_name] if aa_config[:class_name] + + # Generate from manifest name + if manifest.name.present? + name = manifest.name.to_s.tr("-", "_").camelize + name += "Agent" unless name.end_with?("Agent") + name + end + end + + # Register the class as a constant + def register_constant(klass, name, namespace) + target = namespace || Object + + if target.const_defined?(name, false) + target.send(:remove_const, name) + end + + target.const_set(name, klass) + end + + # Deep symbolize keys + def symbolize_keys(hash) + return hash unless hash.is_a?(Hash) + + hash.transform_keys(&:to_sym).transform_values do |v| + v.is_a?(Hash) ? symbolize_keys(v) : v + end + end + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/errors.rb b/lib/solid_agent/agent_manifest/errors.rb new file mode 100644 index 0000000..e34d861 --- /dev/null +++ b/lib/solid_agent/agent_manifest/errors.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + # Base error class for all AgentManifest errors + class Error < SolidAgent::Error; end + + # Raised when parsing a manifest file fails + class ParseError < Error; end + + # Raised when manifest validation fails + class ValidationError < Error; end + + # Raised when an unknown format is encountered + class UnknownFormatError < Error; end + + # Raised when exporting a manifest fails + class ExportError < Error; end + + # Raised when registry operations fail + class RegistryError < Error; end + + # Raised when schema parsing or conversion fails + class SchemaError < Error; end + end +end diff --git a/lib/solid_agent/agent_manifest/exporter_registry.rb b/lib/solid_agent/agent_manifest/exporter_registry.rb new file mode 100644 index 0000000..a983769 --- /dev/null +++ b/lib/solid_agent/agent_manifest/exporter_registry.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + # ExporterRegistry manages format exporters for converting Manifests to various formats. + # + # @example Register an exporter + # ExporterRegistry.register(:agent_md, AgentMdExporter) + # + # @example Export a manifest + # content = ExporterRegistry.export(manifest, :dotprompt) + # + # @example Export to file + # ExporterRegistry.export_to_file(manifest, :agent_md, "agent.agent.md") + # + class ExporterRegistry + class << self + # Registered exporters by format name + # + # @return [Hash] + def exporters + @exporters ||= {} + end + + # Register an exporter for a format + # + # @param format [Symbol] Format name + # @param exporter_class [Class] Exporter class + def register(format, exporter_class) + exporters[format.to_sym] = exporter_class + end + + # Get the exporter for a format + # + # @param format [Symbol, String] Format name + # @return [Class] Exporter class + # @raise [UnknownFormatError] if format not recognized + def exporter_for(format) + format_sym = format.to_sym + exporters[format_sym] || raise(UnknownFormatError, "Unknown export format: #{format}") + end + + # Check if a format is supported + # + # @param format [Symbol, String] Format name + # @return [Boolean] + def supports?(format) + exporters.key?(format.to_sym) + end + + # List all registered format names + # + # @return [Array] + def formats + exporters.keys + end + + # Export a manifest to a format string + # + # @param manifest [Manifest] The manifest to export + # @param format [Symbol, String] Target format + # @param options [Hash] Format-specific options + # @return [String] Exported content + def export(manifest, format, **options) + exporter = exporter_for(format) + exporter.export(manifest, **options) + end + + # Export a manifest to a file + # + # @param manifest [Manifest] The manifest to export + # @param format [Symbol, String] Target format + # @param path [String] Output file path + # @param options [Hash] Format-specific options + # @return [String] The path written to + def export_to_file(manifest, format, path, **options) + content = export(manifest, format, **options) + File.write(path, content, encoding: "UTF-8") + path + end + + # Convert between formats + # + # @param input_path [String] Source file path + # @param output_format [Symbol, String] Target format + # @param output_path [String, nil] Output path (auto-generated if nil) + # @return [String] Output path + def convert(input_path, output_format, output_path = nil) + manifest = ParserRegistry.parse(input_path) + + output_path ||= generate_output_path(input_path, output_format) + export_to_file(manifest, output_format, output_path) + end + + private + + # Generate output path based on format + def generate_output_path(input_path, format) + base = File.basename(input_path, ".*") + # Remove any existing format extensions + base = base.sub(/\.(agent|prompt)$/, "") + dir = File.dirname(input_path) + + extension = case format.to_sym + when :agent_md then ".agent.md" + when :dotprompt then ".prompt" + when :crewai then ".yaml" + when :github_prompt then ".prompt.md" + else ".#{format}" + end + + File.join(dir, "#{base}#{extension}") + end + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb b/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb new file mode 100644 index 0000000..b8c0a2e --- /dev/null +++ b/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + module Exporters + # AgentMdExporter converts Manifests to the native .agent.md format. + # + # @example Export a manifest + # content = AgentMdExporter.export(manifest) + # + # @example Export with options + # content = AgentMdExporter.export(manifest, include_examples: true) + # + class AgentMdExporter < BaseExporter + class << self + def format_name + :agent_md + end + + # Export manifest to .agent.md format + # + # @param manifest [Manifest] The manifest to export + # @param include_examples [Boolean] Include examples section + # @param include_tests [Boolean] Include tests section + # @return [String] Exported content + def export(manifest, include_examples: false, include_tests: false, **) + frontmatter = build_agent_md_frontmatter(manifest, include_examples, include_tests) + body = build_body(manifest) + + "#{frontmatter}\n#{body}" + end + + private + + def build_agent_md_frontmatter(manifest, include_examples, include_tests) + data = {} + + # Meta + data["name"] = manifest.name + data["version"] = manifest.version if manifest.version && manifest.version != "1.0.0" + data["description"] = manifest.description + data["author"] = manifest.author + data["license"] = manifest.license + data["repository"] = manifest.repository + data["tags"] = manifest.tags if manifest.tags&.any? + data["extends"] = manifest.extends + + # Model + data["model"] = manifest.model + data["config"] = manifest.config if manifest.config&.any? + + # Schemas + data["input"] = export_input_schema(manifest.input_schema) + data["output"] = manifest.output_schema + + # Tools & Resources + data["tools"] = export_tools(manifest.tools) + data["resources"] = export_resources(manifest.resources) + + # Extensions (preserve framework-specific config) + manifest.extensions&.each do |framework, config| + data[framework.to_s] = config if config&.any? + end + + # Examples & Tests + data["examples"] = manifest.examples if include_examples && manifest.examples&.any? + data["tests"] = manifest.tests if include_tests && manifest.tests&.any? + + build_frontmatter(data) + end + + def build_body(manifest) + parts = [] + + # Title from name + title = manifest.name.to_s.tr("-", " ").split.map(&:capitalize).join(" ") + parts << "# #{title}\n" + + # Description + if manifest.description.present? + parts << manifest.description + parts << "" + end + + # Instructions section + if manifest.instructions.present? && manifest.instructions != manifest.template + parts << "## Instructions\n" + parts << manifest.instructions + parts << "" + end + + # Template (if different from instructions or if instructions is nil) + if manifest.template.present? + # Check if template is just the full body or needs a section + if manifest.instructions.blank? + # Template is the main content + parts << manifest.template + elsif manifest.template != manifest.instructions + parts << "## Template\n" + parts << "```liquid" + parts << manifest.template + parts << "```" + end + end + + parts.join("\n").strip + "\n" + end + end + end + + # Register the exporter + ExporterRegistry.register(:agent_md, AgentMdExporter) + end + end +end diff --git a/lib/solid_agent/agent_manifest/exporters/base_exporter.rb b/lib/solid_agent/agent_manifest/exporters/base_exporter.rb new file mode 100644 index 0000000..ea02c3b --- /dev/null +++ b/lib/solid_agent/agent_manifest/exporters/base_exporter.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + module Exporters + # BaseExporter provides common functionality for all format exporters. + # + # Subclasses should implement: + # - .export(manifest, **options) - Convert manifest to string + # - .format_name - Return the format identifier symbol + # + class BaseExporter + class << self + # Export a manifest to string format + # + # @param manifest [Manifest] The manifest to export + # @param options [Hash] Format-specific options + # @return [String] Exported content + def export(manifest, **options) + raise NotImplementedError, "#{self}.export must be implemented by subclass" + end + + # Get the format name for this exporter + # + # @return [Symbol] + def format_name + raise NotImplementedError, "#{self}.format_name must be implemented by subclass" + end + + protected + + # Build YAML frontmatter from a hash + # + # @param data [Hash] Frontmatter data + # @return [String] YAML frontmatter block + def build_frontmatter(data) + # Remove nil values and empty hashes/arrays + clean_data = deep_compact(data) + return "" if clean_data.empty? + + yaml_content = clean_data.to_yaml + # Remove the leading "---\n" that to_yaml adds + yaml_content = yaml_content.sub(/\A---\n/, "") + + "---\n#{yaml_content}---\n" + end + + # Deep compact - remove nil values and empty collections recursively + # + # @param obj [Object] Object to compact + # @return [Object] Compacted object + def deep_compact(obj) + case obj + when Hash + result = {} + obj.each do |k, v| + compacted = deep_compact(v) + result[k] = compacted unless empty_value?(compacted) + end + result + when Array + obj.map { |v| deep_compact(v) }.reject { |v| empty_value?(v) } + else + obj + end + end + + # Check if a value should be considered empty + # + # @param value [Object] + # @return [Boolean] + def empty_value?(value) + case value + when nil then true + when Hash then value.empty? + when Array then value.empty? + when String then value.empty? + else false + end + end + + # Convert tools to exportable format + # + # @param tools [Array] Tools to convert + # @return [Array] Exportable tool data + def export_tools(tools) + return nil if tools.nil? || tools.empty? + + tools.map do |tool| + if tool.reference? + { "$ref" => tool.ref } + else + { + "name" => tool.name, + "description" => tool.description, + "inputSchema" => tool.input_schema + }.compact + end + end + end + + # Convert resources to exportable format + # + # @param resources [Array] Resources to convert + # @return [Array] Exportable resource data + def export_resources(resources) + return nil if resources.nil? || resources.empty? + + resources.map do |resource| + { + "name" => resource.name, + "description" => resource.description, + "uri" => resource.uri, + "mimeType" => resource.mime_type + }.compact + end + end + + # Convert input schema to exportable format + # + # @param schema [InputSchema, nil] Schema to convert + # @return [Hash, nil] Exportable schema data + def export_input_schema(schema) + return nil unless schema + + { "schema" => schema.schema } + end + + # Strip provider prefix from model identifier + # + # @param model [String, nil] Full model identifier + # @return [String, nil] Model name without provider + def strip_provider(model) + return nil unless model + model.to_s.split("/").last + end + + # Wrap text at specified width + # + # @param text [String] Text to wrap + # @param width [Integer] Maximum line width + # @return [String] Wrapped text + def word_wrap(text, width: 80) + return text unless text + + text.gsub(/(.{1,#{width}})(\s+|$)/, "\\1\n").strip + end + end + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb b/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb new file mode 100644 index 0000000..df5f7ce --- /dev/null +++ b/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + module Exporters + # CrewAIExporter converts Manifests to CrewAI's agents.yaml format. + # + # @see https://docs.crewai.com + # + # @example Export a single manifest + # content = CrewAIExporter.export(manifest) + # + # @example Export multiple manifests + # content = CrewAIExporter.export_multiple([manifest1, manifest2]) + # + class CrewAIExporter < BaseExporter + class << self + def format_name + :crewai + end + + # Export manifest to CrewAI agents.yaml format + # + # @param manifest [Manifest] The manifest to export + # @return [String] Exported YAML content + def export(manifest, **) + export_multiple([manifest]) + end + + # Export multiple manifests to a single agents.yaml + # + # @param manifests [Array] Manifests to export + # @return [String] Exported YAML content + def export_multiple(manifests) + agents = {} + + manifests.each do |manifest| + agent_key = manifest.name.to_s.tr("-", "_") + agents[agent_key] = build_crewai_agent(manifest) + end + + # Use YAML dump with custom options for better formatting + yaml = agents.to_yaml + # Remove leading --- + yaml.sub(/\A---\n/, "") + end + + private + + def build_crewai_agent(manifest) + agent = {} + + # Extract from CrewAI extensions if present + crewai_ext = manifest.extensions&.dig(:crewai) || {} + + # Role - from extension or generate from name + agent["role"] = crewai_ext[:role] || crewai_ext["role"] || + manifest.name.to_s.tr("-", " ").split.map(&:capitalize).join(" ") + + # Goal - from extension or description + agent["goal"] = crewai_ext[:goal] || crewai_ext["goal"] || manifest.description + + # Backstory - from extension or instructions + backstory = crewai_ext[:backstory] || crewai_ext["backstory"] + if backstory.blank? && manifest.instructions.present? + # Extract backstory from instructions if it follows the Role/Goal/Backstory format + backstory = extract_backstory(manifest.instructions) + end + agent["backstory"] = backstory if backstory.present? + + # LLM - strip provider prefix for CrewAI + agent["llm"] = strip_provider(manifest.model) if manifest.model.present? + + # Tools - CrewAI uses Python class names + if manifest.tools&.any? + agent["tools"] = manifest.tools.map do |tool| + if tool.reference? + # Extract tool name from reference + tool.ref.to_s.split("://").last.split("/").last + else + # Convert to PascalCase tool class name + tool.name.to_s.split(/[-_]/).map(&:capitalize).join + "Tool" + end + end + end + + # CrewAI-specific options from extensions + %w[verbose allow_delegation max_iter max_rpm memory cache].each do |key| + value = crewai_ext[key.to_sym] || crewai_ext[key] + agent[key] = value unless value.nil? + end + + # Config options + if manifest.config&.any? + agent["temperature"] = manifest.config[:temperature] || manifest.config["temperature"] + agent["max_tokens"] = manifest.config[:max_tokens] || manifest.config["max_tokens"] + end + + agent.compact + end + + # Extract backstory from instructions that follow Role/Goal/Backstory format + def extract_backstory(instructions) + return nil unless instructions + + # Look for content after "Backstory:" or similar patterns + if instructions =~ /backstory:?\s*(.+?)(?:\n\n|\z)/mi + return ::Regexp.last_match(1).strip + end + + # If instructions don't have Role/Goal prefix, use the whole thing as backstory + unless instructions =~ /\A\s*(Role:|Goal:)/i + return instructions + end + + nil + end + end + end + + # Register the exporter + ExporterRegistry.register(:crewai, CrewAIExporter) + end + end +end diff --git a/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb b/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb new file mode 100644 index 0000000..df5e53f --- /dev/null +++ b/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + module Exporters + # DotpromptExporter converts Manifests to Google Genkit's .prompt format. + # + # @see https://github.com/google/dotprompt + # + # @example Export a manifest + # content = DotpromptExporter.export(manifest) + # + class DotpromptExporter < BaseExporter + class << self + def format_name + :dotprompt + end + + # Export manifest to .prompt format + # + # @param manifest [Manifest] The manifest to export + # @param use_picoschema [Boolean] Export input schema as Picoschema + # @return [String] Exported content + def export(manifest, use_picoschema: true, **) + frontmatter = build_dotprompt_frontmatter(manifest, use_picoschema) + body = manifest.template || manifest.instructions || "" + + "#{frontmatter}\n#{body.strip}\n" + end + + private + + def build_dotprompt_frontmatter(manifest, use_picoschema) + data = {} + + # Basic fields + data["name"] = manifest.name if manifest.name.present? + data["model"] = manifest.model + data["description"] = manifest.description + + # Config fields - Dotprompt uses camelCase + if manifest.config&.any? + data["temperature"] = manifest.config[:temperature] || manifest.config["temperature"] + data["maxOutputTokens"] = manifest.config[:max_tokens] || manifest.config["max_tokens"] + data["topP"] = manifest.config[:top_p] || manifest.config["top_p"] + data["topK"] = manifest.config[:top_k] || manifest.config["top_k"] + data["stopSequences"] = manifest.config[:stop] || manifest.config["stop"] + end + + # Input schema + if manifest.input_schema + input_data = {} + if use_picoschema + input_data["schema"] = manifest.input_schema.to_picoschema + else + input_data["schema"] = manifest.input_schema.to_json_schema + end + data["input"] = input_data + end + + # Output schema + if manifest.output_schema + data["output"] = manifest.output_schema + end + + # Tools - Dotprompt format + if manifest.tools&.any? + data["tools"] = manifest.tools.map do |tool| + if tool.reference? + tool.ref + else + tool.name + end + end + end + + # Preserve dotprompt-specific extensions + dotprompt_ext = manifest.extensions&.dig(:dotprompt) || {} + %w[candidates cache default variant metadata].each do |key| + data[key] = dotprompt_ext[key] if dotprompt_ext[key].present? + end + + build_frontmatter(data) + end + end + end + + # Register the exporter + ExporterRegistry.register(:dotprompt, DotpromptExporter) + end + end +end diff --git a/lib/solid_agent/agent_manifest/input_schema.rb b/lib/solid_agent/agent_manifest/input_schema.rb new file mode 100644 index 0000000..b027ecb --- /dev/null +++ b/lib/solid_agent/agent_manifest/input_schema.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + # InputSchema wraps a schema definition, supporting both Picoschema + # (compact YAML format) and JSON Schema formats. + # + # Provides bidirectional conversion between formats for interoperability. + # + # @example Picoschema format + # schema = InputSchema.new( + # schema: { + # "query" => "string, the search query", + # "limit?" => "integer, max results" + # }, + # format: :picoschema + # ) + # schema.to_json_schema # => JSON Schema object + # + # @example JSON Schema format + # schema = InputSchema.new( + # schema: { + # "type" => "object", + # "properties" => { "query" => { "type" => "string" } } + # }, + # format: :json_schema + # ) + # schema.to_picoschema # => Picoschema object + # + class InputSchema + # @return [Hash] The schema definition + attr_accessor :schema + + # @return [Symbol] Schema format (:picoschema or :json_schema) + attr_accessor :format + + def initialize(schema:, format: :picoschema) + @schema = schema + @format = format.to_sym + end + + # Convert schema to JSON Schema format + # + # @return [Hash] JSON Schema object + def to_json_schema + case format + when :picoschema + Picoschema.to_json_schema(schema) + when :json_schema + schema + else + raise SchemaError, "Unknown schema format: #{format}" + end + end + + # Convert schema to Picoschema format + # + # @return [Hash] Picoschema object + def to_picoschema + case format + when :picoschema + schema + when :json_schema + Picoschema.from_json_schema(schema) + else + raise SchemaError, "Unknown schema format: #{format}" + end + end + + # Convert to hash representation + # + # @return [Hash] + def to_h + { + schema: schema, + format: format + } + end + + # Validate input data against this schema + # + # Requires the json_schemer gem for validation. + # + # @param input_hash [Hash] Input data to validate + # @return [Array] List of validation errors (empty if valid) + def validate_input(input_hash) + return [] unless defined?(JSONSchemer) + + json_schema = to_json_schema + schemer = JSONSchemer.schema(json_schema) + schemer.validate(input_hash).map { |error| error["error"] } + rescue => e + ["Schema validation error: #{e.message}"] + end + + # Check if input is valid against schema + # + # @param input_hash [Hash] Input data to validate + # @return [Boolean] + def valid_input?(input_hash) + validate_input(input_hash).empty? + end + + # Get list of required field names + # + # @return [Array] + def required_fields + json = to_json_schema + json["required"] || [] + end + + # Get all field names + # + # @return [Array] + def field_names + json = to_json_schema + (json["properties"] || {}).keys + end + + # Get field definition + # + # @param field_name [String] Name of the field + # @return [Hash, nil] Field schema definition + def field(field_name) + json = to_json_schema + json.dig("properties", field_name.to_s) + end + + # Create from various input formats + # + # @param data [Hash] Schema data + # @return [InputSchema] + def self.from_hash(data) + schema = data["schema"] || data[:schema] + format = data["format"] || data[:format] || detect_format(schema) + + new(schema: schema, format: format) + end + + # Detect schema format from content + # + # @param schema [Hash] Schema to analyze + # @return [Symbol] Detected format + def self.detect_format(schema) + return :json_schema if schema.nil? + return :json_schema if schema["type"].present? + return :json_schema if schema["properties"].present? + return :json_schema if schema["$schema"].present? + + :picoschema + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/manifest.rb b/lib/solid_agent/agent_manifest/manifest.rb new file mode 100644 index 0000000..1e6d848 --- /dev/null +++ b/lib/solid_agent/agent_manifest/manifest.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + # Manifest represents a unified, portable AI agent definition. + # + # It serves as the canonical internal representation that all parsers + # produce and all exporters consume, enabling cross-framework compatibility. + # + # @example Creating a manifest + # manifest = Manifest.new( + # name: "research-assistant", + # model: "anthropic/claude-sonnet-4-20250514", + # description: "An agent that researches topics" + # ) + # + # @example Validating a manifest + # manifest.valid? # => true/false + # manifest.errors.full_messages # => ["Name can't be blank"] + # + class Manifest + include ActiveModel::Model + include ActiveModel::Validations + + # === Meta attributes === + # @return [String] Unique identifier (lowercase, hyphens only) + attr_accessor :name + + # @return [String] SemVer version string (default: "1.0.0") + attr_accessor :version + + # @return [String, nil] Brief description (max 280 chars) + attr_accessor :description + + # @return [String, nil] Author name or organization + attr_accessor :author + + # @return [String, nil] SPDX license identifier + attr_accessor :license + + # @return [String, nil] Source repository URL + attr_accessor :repository + + # @return [Array] Categorization tags + attr_accessor :tags + + # @return [String, nil] Parent agent to inherit from + attr_accessor :extends + + # === Model configuration === + # @return [String, nil] Model identifier (provider/model format) + attr_accessor :model + + # @return [Hash] Model-specific parameters (temperature, max_tokens, etc.) + attr_accessor :config + + # === Schemas === + # @return [InputSchema, nil] Expected input structure + attr_accessor :input_schema + + # @return [Hash, nil] Expected output format and structure + attr_accessor :output_schema + + # === Tools & Resources === + # @return [Array] Available tools + attr_accessor :tools + + # @return [Array] External data sources + attr_accessor :resources + + # === Instructions === + # @return [String, nil] Extracted instructions from template + attr_accessor :instructions + + # @return [String, nil] Full template content (with Liquid syntax) + attr_accessor :template + + # === Framework extensions === + # @return [Hash] Framework-specific configuration (activeagent:, crewai:, etc.) + attr_accessor :extensions + + # === Source tracking === + # @return [Symbol, nil] Original format (:agent_md, :dotprompt, etc.) + attr_accessor :source_format + + # @return [String, nil] Original file path + attr_accessor :source_path + + # === Examples & Tests === + # @return [Array] Example inputs/outputs + attr_accessor :examples + + # @return [Array] Test cases + attr_accessor :tests + + # === Validations === + validates :name, presence: true, + format: { + with: /\A[a-z][a-z0-9\-]*\z/, + message: "must start with lowercase letter and contain only lowercase letters, numbers, and hyphens" + }, + allow_blank: false + + validates :version, + format: { + with: /\A\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?\z/, + message: "must be valid semver (e.g., 1.0.0, 1.0.0-beta.1)" + }, + allow_blank: true + + validates :model, + format: { + with: %r{\A[a-z0-9_-]+/[a-z0-9._-]+\z}i, + message: "must be in provider/model format (e.g., anthropic/claude-sonnet-4-20250514)" + }, + allow_blank: true + + validate :validate_tools + validate :validate_extensions + + def initialize(attributes = {}) + # Set defaults before calling super + @version = "1.0.0" + @tags = [] + @tools = [] + @resources = [] + @extensions = {} + @config = {} + @examples = [] + @tests = [] + + super(attributes) + + # Ensure arrays and hashes are properly initialized even if nil was passed + @tags ||= [] + @tools ||= [] + @resources ||= [] + @extensions ||= {} + @config ||= {} + @examples ||= [] + @tests ||= [] + end + + # === Convenience accessors for framework extensions === + + # @return [Hash] ActiveAgent-specific configuration + def activeagent_config + extensions[:activeagent] || extensions["activeagent"] || {} + end + + # @return [Hash] CrewAI-specific configuration + def crewai_config + extensions[:crewai] || extensions["crewai"] || {} + end + + # @return [Hash] LangChain-specific configuration + def langchain_config + extensions[:langchain] || extensions["langchain"] || {} + end + + # @return [Hash] Genkit/Dotprompt-specific configuration + def genkit_config + extensions[:genkit] || extensions["genkit"] || {} + end + + # Convert manifest to a hash representation + # + # @return [Hash] Hash representation suitable for serialization + def to_h + { + name: name, + version: version, + description: description, + author: author, + license: license, + repository: repository, + tags: tags.presence, + extends: extends, + model: model, + config: config.presence, + input_schema: input_schema&.to_h, + output_schema: output_schema, + tools: tools.map(&:to_h).presence, + resources: resources.map(&:to_h).presence, + instructions: instructions, + template: template, + extensions: extensions.presence + }.compact + end + + # Convert to JSON string + # + # @return [String] JSON representation + def to_json(*args) + to_h.to_json(*args) + end + + # Check if this manifest has any tools defined + # + # @return [Boolean] + def has_tools? + tools.any? + end + + # Check if this manifest has any resources defined + # + # @return [Boolean] + def has_resources? + resources.any? + end + + # Check if this manifest extends another agent + # + # @return [Boolean] + def extends? + extends.present? + end + + # Get tool by name + # + # @param tool_name [String, Symbol] Name of the tool + # @return [Tool, nil] + def tool(tool_name) + tools.find { |t| t.name.to_s == tool_name.to_s } + end + + private + + def validate_tools + tools.each_with_index do |tool, index| + next if tool.is_a?(Tool) + + errors.add(:tools, "item at index #{index} must be a Tool instance") + end + end + + def validate_extensions + return unless extensions.present? + + unless extensions.is_a?(Hash) + errors.add(:extensions, "must be a hash") + return + end + + # Validate known extension structures + if activeagent_config.present? && !activeagent_config.is_a?(Hash) + errors.add(:extensions, "activeagent config must be a hash") + end + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/parser_registry.rb b/lib/solid_agent/agent_manifest/parser_registry.rb new file mode 100644 index 0000000..85ec2d9 --- /dev/null +++ b/lib/solid_agent/agent_manifest/parser_registry.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + # ParserRegistry manages format parsers and provides format detection. + # + # It enables automatic detection of file formats and routes parsing + # to the appropriate parser class. + # + # @example Parsing a file + # manifest = ParserRegistry.parse("agent.agent.md") + # manifest = ParserRegistry.parse("agent.prompt", format: :dotprompt) + # + # @example Registering a custom parser + # ParserRegistry.register(:custom, MyCustomParser) + # + class ParserRegistry + class << self + # Registered parsers by format + # + # @return [Hash{Symbol => Class}] + def parsers + @parsers ||= {} + end + + # Register a parser for a format + # + # @param format [Symbol] Format identifier + # @param parser_class [Class] Parser class (must respond to .parse and .parse_string) + def register(format, parser_class) + parsers[format.to_sym] = parser_class + end + + # Get parser for a format + # + # @param format [Symbol] Format identifier + # @return [Class] Parser class + # @raise [UnknownFormatError] if no parser is registered + def parser_for(format) + parsers[format.to_sym] || raise(UnknownFormatError, "No parser registered for format: #{format}") + end + + # Detect format from file path + # + # @param path [String] File path + # @return [Symbol] Detected format + # @raise [UnknownFormatError] if format cannot be detected + def detect_format(path) + extension = File.extname(path).downcase + + case extension + when ".md" + detect_markdown_format(path) + when ".prompt" + :dotprompt + when ".yaml", ".yml" + detect_yaml_format(path) + when ".json" + detect_json_format(path) + else + raise UnknownFormatError, "Cannot detect format for extension: #{extension}" + end + end + + # Parse a file, auto-detecting format if not specified + # + # @param path [String] Path to the file + # @param format [Symbol, nil] Optional format override + # @return [Manifest] Parsed manifest + def parse(path, format: nil) + format ||= detect_format(path) + parser_for(format).parse(path) + end + + # Parse content string with explicit format + # + # @param content [String] File content + # @param format [Symbol] Format identifier + # @return [Manifest] Parsed manifest + def parse_string(content, format:) + parser_for(format).parse_string(content) + end + + # List all registered formats + # + # @return [Array] + def registered_formats + parsers.keys + end + + # Alias for registered_formats + # @return [Array] + def formats + registered_formats + end + + # Check if a format is supported + # + # @param format [Symbol] Format to check + # @return [Boolean] + def format_supported?(format) + parsers.key?(format.to_sym) + end + + # Alias for format_supported? + # @return [Boolean] + def supports?(format) + format_supported?(format) + end + + private + + # Detect specific markdown format (agent.md vs prompt.md) + def detect_markdown_format(path) + basename = File.basename(path) + + if basename.end_with?(".agent.md") + :agent_md + elsif basename.end_with?(".prompt.md") + :github_prompt + else + # Could be either - try to detect from content + detect_markdown_format_from_content(path) + end + end + + # Detect markdown format from file content + def detect_markdown_format_from_content(path) + return :agent_md unless File.exist?(path) + + content = File.read(path, encoding: "UTF-8") + + # Look for distinctive markers in frontmatter + if content.match?(/^---\s*\n.*?^activeagent:/m) + :agent_md + elsif content.match?(/^---\s*\n.*?^agent:/m) + :github_prompt + else + # Default to agent_md for generic markdown with frontmatter + :agent_md + end + rescue + :agent_md + end + + # Detect YAML format (CrewAI vs other) + def detect_yaml_format(path) + return :crewai unless File.exist?(path) + + content = File.read(path, encoding: "UTF-8") + + # CrewAI typically has role/goal/backstory pattern + if content.match?(/role:\s*[>\|]?\s*\n?\s*.+/i) && + content.match?(/goal:\s*[>\|]?\s*\n?\s*.+/i) + :crewai + elsif content.match?(/has_context|activeagent/i) + :activeagent_yaml + else + :yaml + end + rescue + :yaml + end + + # Detect JSON format + def detect_json_format(path) + return :json unless File.exist?(path) + + content = File.read(path, encoding: "UTF-8") + data = JSON.parse(content) + + if data["inputSchema"] + :mcp_tool + elsif data["main"] && data["files"] + :agent_package + else + :json + end + rescue + :json + end + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb b/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb new file mode 100644 index 0000000..d999d8a --- /dev/null +++ b/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + module Parsers + # AgentMdParser handles the native .agent.md format. + # + # This is the primary format for ActiveAgent manifests, featuring + # YAML frontmatter with comprehensive metadata and a Markdown body + # containing instructions and Liquid templates. + # + # @example Basic .agent.md file + # --- + # name: research-assistant + # model: anthropic/claude-sonnet-4-20250514 + # --- + # + # # Research Assistant + # + # You help users research topics thoroughly. + # + class AgentMdParser < BaseParser + class << self + def format_name + :agent_md + end + + def parse_string(content) + frontmatter, body = extract_frontmatter(content) + + Manifest.new( + # Meta + name: frontmatter["name"], + version: frontmatter["version"], + description: frontmatter["description"], + author: frontmatter["author"], + license: frontmatter["license"], + repository: frontmatter["repository"], + tags: parse_tags(frontmatter["tags"]), + extends: frontmatter["extends"], + + # Model + model: normalize_model(frontmatter["model"]), + config: parse_config(frontmatter), + + # Schemas + input_schema: parse_input_schema(frontmatter["input"]), + output_schema: frontmatter["output"], + + # Tools & Resources + tools: parse_tools(frontmatter["tools"]), + resources: parse_resources(frontmatter["resources"]), + + # Instructions + instructions: extract_instructions(body), + template: body.presence, + + # Extensions + extensions: extract_extensions(frontmatter), + + # Examples & Tests + examples: frontmatter["examples"] || [], + tests: frontmatter["tests"] || [] + ) + end + + private + + # Parse tags ensuring array format + def parse_tags(tags_data) + case tags_data + when Array + tags_data.map(&:to_s) + when String + tags_data.split(",").map(&:strip) + else + [] + end + end + end + end + + # Register the parser + ParserRegistry.register(:agent_md, AgentMdParser) + end + end +end diff --git a/lib/solid_agent/agent_manifest/parsers/base_parser.rb b/lib/solid_agent/agent_manifest/parsers/base_parser.rb new file mode 100644 index 0000000..63912ab --- /dev/null +++ b/lib/solid_agent/agent_manifest/parsers/base_parser.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + module Parsers + # BaseParser provides common functionality for all format parsers. + # + # Subclasses should implement: + # - .parse_string(content) - Parse content string into a Manifest + # - .format_name - Return the format identifier symbol + # + class BaseParser + FRONTMATTER_REGEX = /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m + + class << self + # Parse a file into a Manifest + # + # @param path [String] Path to the file + # @return [Manifest] Parsed manifest + def parse(path) + content = File.read(path, encoding: "UTF-8") + manifest = parse_string(content) + + # Handle array results (e.g., CrewAI with multiple agents) + if manifest.is_a?(Array) + manifest.each do |m| + m.source_path = path + m.source_format = format_name + end + else + manifest.source_path = path + manifest.source_format = format_name + end + + manifest + end + + # Parse content string into a Manifest + # + # @param content [String] File content + # @return [Manifest] Parsed manifest + def parse_string(content) + raise NotImplementedError, "#{self}.parse_string must be implemented by subclass" + end + + # Get the format name for this parser + # + # @return [Symbol] + def format_name + raise NotImplementedError, "#{self}.format_name must be implemented by subclass" + end + + protected + + # Extract YAML frontmatter from markdown content + # + # @param content [String] Full content with frontmatter + # @return [Array] [frontmatter_hash, body] + def extract_frontmatter(content) + return [{}, content.strip] if content.blank? + + match = content.match(FRONTMATTER_REGEX) + + unless match + # Check if frontmatter was intended but malformed + if content.strip.start_with?("---") + raise ParseError, "Malformed frontmatter: missing closing '---' delimiter" + end + return [{}, content.strip] + end + + begin + frontmatter = YAML.safe_load( + match[1], + permitted_classes: [Symbol, Date, Time], + permitted_symbols: [], + aliases: false + ) + rescue Psych::SyntaxError => e + raise ParseError, "Invalid YAML in frontmatter: #{e.message}" + end + + [frontmatter || {}, match[2].strip] + end + + # Normalize model identifier to provider/model format + # + # @param model_string [String, nil] Model identifier + # @return [String, nil] Normalized model identifier + def normalize_model(model_string) + return nil unless model_string.present? + + model_string = model_string.to_s.strip + + # Already in provider/model format + return model_string if model_string.include?("/") + + # Detect provider from model name + if model_string.start_with?("gpt-") || model_string.start_with?("o1") || model_string.start_with?("o3") + "openai/#{model_string}" + elsif model_string.start_with?("claude-") + "anthropic/#{model_string}" + elsif model_string.start_with?("gemini-") + "google/#{model_string}" + elsif model_string.start_with?("llama") || model_string.start_with?("mixtral") + "meta/#{model_string}" + else + # Unknown - return as-is + model_string + end + end + + # Parse tools array from frontmatter data + # + # @param tools_data [Array, nil] Tools data from frontmatter + # @return [Array] Parsed tools + def parse_tools(tools_data) + return [] unless tools_data.is_a?(Array) + + tools_data.map do |tool_data| + if tool_data.is_a?(String) + # String reference + Tool.new(ref: tool_data) + elsif tool_data["$ref"] + # Explicit reference + Tool.new(ref: tool_data["$ref"]) + else + # Inline definition + Tool.from_hash(tool_data) + end + end + end + + # Parse resources array from frontmatter data + # + # @param resources_data [Array, nil] Resources data from frontmatter + # @return [Array] Parsed resources + def parse_resources(resources_data) + return [] unless resources_data.is_a?(Array) + + resources_data.map { |data| Resource.from_hash(data) } + end + + # Parse input schema from frontmatter data + # + # @param input_data [Hash, nil] Input schema data + # @return [InputSchema, nil] Parsed input schema + def parse_input_schema(input_data) + return nil unless input_data.is_a?(Hash) + + schema = input_data["schema"] || input_data[:schema] + return nil unless schema + + format = InputSchema.detect_format(schema) + InputSchema.new(schema: schema, format: format) + end + + # Extract framework extensions from frontmatter + # + # @param frontmatter [Hash] Full frontmatter hash + # @return [Hash] Framework extensions + def extract_extensions(frontmatter) + extensions = {} + + %w[activeagent crewai langchain genkit dotprompt].each do |framework| + value = frontmatter[framework] || frontmatter[framework.to_sym] + extensions[framework.to_sym] = value if value.present? + end + + extensions + end + + # Extract instructions from markdown body + # + # @param body [String] Markdown body + # @return [String, nil] Extracted instructions + def extract_instructions(body) + return nil if body.blank? + + # Try to find ## Instructions section + if body =~ /##\s*Instructions\s*\n(.*?)(?=\n##|\z)/mi + return ::Regexp.last_match(1).strip + end + + # Try ## Instruct (abbreviated) + if body =~ /##\s*Instruct\w*\s*\n(.*?)(?=\n##|\z)/mi + return ::Regexp.last_match(1).strip + end + + # Fall back to body without title + body.gsub(/\A#\s+[^\n]+\n+/, "").strip.presence + end + + # Parse config/model parameters + # + # @param frontmatter [Hash] Frontmatter hash + # @return [Hash] Config hash + def parse_config(frontmatter) + config = frontmatter["config"] || frontmatter[:config] || {} + + # Also check for top-level config keys (Dotprompt style) + %w[temperature max_tokens maxOutputTokens top_p topP top_k topK stop stopSequences].each do |key| + value = frontmatter[key] || frontmatter[key.to_sym] + if value.present? + # Normalize key names + normalized_key = case key + when "maxOutputTokens" then "max_tokens" + when "topP" then "top_p" + when "topK" then "top_k" + when "stopSequences" then "stop" + else key + end + config[normalized_key] = value + end + end + + config.presence || {} + end + end + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb b/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb new file mode 100644 index 0000000..e0fca70 --- /dev/null +++ b/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + module Parsers + # CrewAIParser handles CrewAI's agents.yaml format. + # + # CrewAI defines agents with role, goal, and backstory. + # This parser converts them to the unified Manifest format. + # + # @see https://docs.crewai.com + # + # @example agents.yaml + # research_analyst: + # role: > + # Senior Research Analyst + # goal: > + # Uncover cutting-edge developments + # backstory: > + # You're a seasoned researcher... + # llm: claude-sonnet-4-20250514 + # tools: + # - SerperDevTool + # + class CrewAIParser < BaseParser + class << self + def format_name + :crewai + end + + def parse_string(content) + data = YAML.safe_load(content, permitted_classes: [Symbol]) + + raise ParseError, "Invalid CrewAI YAML: expected hash" unless data.is_a?(Hash) + + # CrewAI files can contain multiple agents + agents = data.map do |agent_name, agent_data| + parse_agent(agent_name.to_s, agent_data) + end + + # If single agent, return it directly; otherwise return array + agents.size == 1 ? agents.first : agents + end + + # Parse a file containing multiple agents + # + # @param agents_path [String] Path to agents.yaml + # @param tasks_path [String, nil] Optional path to tasks.yaml + # @return [Array] Parsed manifests + def parse_with_tasks(agents_path, tasks_path = nil) + agents_content = File.read(agents_path, encoding: "UTF-8") + result = parse_string(agents_content) + + # Ensure we have an array + manifests = result.is_a?(Array) ? result : [result] + + # Optionally merge task information + if tasks_path && File.exist?(tasks_path) + tasks = YAML.safe_load(File.read(tasks_path, encoding: "UTF-8")) + merge_tasks(manifests, tasks) + end + + manifests + end + + private + + def parse_agent(name, data) + return nil unless data.is_a?(Hash) + + Manifest.new( + # Convert snake_case name to kebab-case + name: name.tr("_", "-"), + description: clean_multiline(data["goal"]), + + # Model + model: normalize_model(data["llm"]), + config: parse_crewai_config(data), + + # Build instructions from role + backstory + instructions: build_instructions(data), + template: build_template(data), + + # Tools (as references to Python classes) + tools: parse_crewai_tools(data["tools"]), + + # Preserve CrewAI-specific config + extensions: { + crewai: build_crewai_extensions(data) + } + ) + end + + # Parse CrewAI config options + def parse_crewai_config(data) + config = {} + + # CrewAI doesn't have direct model config in YAML typically + # but some implementations support it + config[:temperature] = data["temperature"] if data["temperature"] + config[:max_tokens] = data["max_tokens"] if data["max_tokens"] + + config.compact + end + + # Build instructions from role and backstory + def build_instructions(data) + parts = [] + + role = clean_multiline(data["role"]) + goal = clean_multiline(data["goal"]) + backstory = clean_multiline(data["backstory"]) + + parts << "Role: #{role}" if role.present? + parts << "Goal: #{goal}" if goal.present? + parts << "\n#{backstory}" if backstory.present? + + parts.join("\n").strip + end + + # Build a markdown template from CrewAI data + def build_template(data) + role = clean_multiline(data["role"]) || "Agent" + goal = clean_multiline(data["goal"]) + backstory = clean_multiline(data["backstory"]) + + template = "# #{role}\n\n" + template += "## Goal\n\n#{goal}\n\n" if goal + template += "## Backstory\n\n#{backstory}\n\n" if backstory + template + end + + # Parse CrewAI tool references + def parse_crewai_tools(tools_data) + return [] unless tools_data.is_a?(Array) + + tools_data.map do |tool| + if tool.is_a?(String) + # Reference to a Python tool class + Tool.new(ref: "crewai://#{tool}") + elsif tool.is_a?(Hash) + Tool.from_hash(tool) + else + nil + end + end.compact + end + + # Build CrewAI-specific extensions + def build_crewai_extensions(data) + { + role: clean_multiline(data["role"]), + goal: clean_multiline(data["goal"]), + backstory: clean_multiline(data["backstory"]), + verbose: data["verbose"], + allow_delegation: data["allow_delegation"], + max_iter: data["max_iter"], + max_rpm: data["max_rpm"], + memory: data["memory"], + cache: data["cache"], + step_callback: data["step_callback"], + system_template: data["system_template"], + prompt_template: data["prompt_template"], + response_template: data["response_template"] + }.compact + end + + # Clean up YAML multiline strings + def clean_multiline(value) + return nil unless value + value.to_s.strip.gsub(/\s+/, " ") + end + + # Merge task information into manifests + def merge_tasks(manifests, tasks) + # Tasks in CrewAI are associated with agents + # This is a simplified merge - full implementation would be more complex + tasks.each do |task_name, task_data| + agent_name = task_data["agent"]&.tr("_", "-") + manifest = manifests.find { |m| m.name == agent_name } + + if manifest + # Add task information to extensions + manifest.extensions[:crewai] ||= {} + manifest.extensions[:crewai][:tasks] ||= [] + manifest.extensions[:crewai][:tasks] << { + name: task_name, + description: task_data["description"], + expected_output: task_data["expected_output"] + } + end + end + end + end + end + + # Register the parser + ParserRegistry.register(:crewai, CrewAIParser) + end + end +end diff --git a/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb b/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb new file mode 100644 index 0000000..d7cbb73 --- /dev/null +++ b/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + module Parsers + # DotpromptParser handles Google Genkit's .prompt format. + # + # Dotprompt files use YAML frontmatter with Handlebars-style templates. + # This parser converts them to the unified Manifest format. + # + # @see https://github.com/google/dotprompt + # + # @example Basic .prompt file + # --- + # model: googleai/gemini-1.5-pro + # input: + # schema: + # text: string + # output: + # format: json + # --- + # + # Extract information from: {{text}} + # + class DotpromptParser < BaseParser + class << self + def format_name + :dotprompt + end + + def parse_string(content) + frontmatter, body = extract_frontmatter(content) + + # Extract name from frontmatter or generate from content + name = frontmatter["name"] || generate_name_from_content(body) + + Manifest.new( + name: name, + version: frontmatter["version"] || "1.0.0", + description: frontmatter["description"], + + # Model - Dotprompt format + model: normalize_model(frontmatter["model"]), + config: parse_dotprompt_config(frontmatter), + + # Schemas + input_schema: parse_input_schema(frontmatter["input"]), + output_schema: parse_output_schema(frontmatter["output"]), + + # Tools (Dotprompt can reference tools) + tools: parse_tools(frontmatter["tools"]), + + # Template + instructions: body, + template: body, + + # Store original Dotprompt config in extensions + extensions: { + dotprompt: extract_dotprompt_extensions(frontmatter) + } + ) + end + + private + + # Generate a name from template content + def generate_name_from_content(body) + # Try to extract from first line/heading + if body =~ /\A#\s+(.+)/ + ::Regexp.last_match(1).strip.downcase.gsub(/\s+/, "-").gsub(/[^a-z0-9-]/, "") + else + "unnamed-prompt" + end + end + + # Parse Dotprompt-specific config fields + def parse_dotprompt_config(frontmatter) + config = {} + + # Map Dotprompt field names to normalized names + config[:temperature] = frontmatter["temperature"] if frontmatter["temperature"] + config[:max_tokens] = frontmatter["maxOutputTokens"] if frontmatter["maxOutputTokens"] + config[:top_p] = frontmatter["topP"] if frontmatter["topP"] + config[:top_k] = frontmatter["topK"] if frontmatter["topK"] + config[:stop] = frontmatter["stopSequences"] if frontmatter["stopSequences"] + + # Also support normalized names + config[:temperature] ||= frontmatter["config"]&.dig("temperature") + config[:max_tokens] ||= frontmatter["config"]&.dig("max_tokens") + + config.compact + end + + # Parse Dotprompt output schema + def parse_output_schema(output_data) + return nil unless output_data.is_a?(Hash) + + { + format: output_data["format"], + schema: output_data["schema"] + }.compact.presence + end + + # Extract Dotprompt-specific extensions that don't map to standard fields + def extract_dotprompt_extensions(frontmatter) + extensions = {} + + # Preserve Dotprompt-specific fields + %w[candidates cache default variant metadata].each do |key| + extensions[key] = frontmatter[key] if frontmatter[key].present? + end + + extensions.presence + end + end + end + + # Register the parser + ParserRegistry.register(:dotprompt, DotpromptParser) + end + end +end diff --git a/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb b/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb new file mode 100644 index 0000000..56cc177 --- /dev/null +++ b/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + module Parsers + # GitHubPromptParser handles GitHub Copilot's .prompt.md format. + # + # GitHub Copilot prompt files use Markdown with YAML frontmatter, + # supporting variables, tool references, and agent configuration. + # + # @see https://docs.github.com/en/copilot/tutorials/customization-library/prompt-files + # + # @example .prompt.md file + # --- + # model: GPT-4o + # tools: ['githubRepo', 'search/codebase'] + # description: 'Generate a new React form component' + # --- + # + # Create a React form component for ${input:formName} + # + class GitHubPromptParser < BaseParser + class << self + def format_name + :github_prompt + end + + def parse_string(content) + frontmatter, body = extract_frontmatter(content) + + Manifest.new( + name: extract_name(frontmatter, body), + description: frontmatter["description"], + version: "1.0.0", + + # Model + model: normalize_github_model(frontmatter["model"]), + config: {}, + + # Tools are GitHub-specific references + tools: parse_github_tools(frontmatter["tools"]), + + # Template with GitHub variable syntax + instructions: body, + template: body, + + # Preserve GitHub-specific config + extensions: { + github_prompt: extract_github_extensions(frontmatter) + } + ) + end + + private + + # Extract name from frontmatter or generate from content + def extract_name(frontmatter, body) + return frontmatter["name"] if frontmatter["name"].present? + + # Try to extract from description + if frontmatter["description"].present? + return frontmatter["description"] + .downcase + .gsub(/[^a-z0-9\s-]/, "") + .gsub(/\s+/, "-") + .slice(0, 50) + end + + # Try to extract from first heading + if body =~ /\A#\s+(.+)/ + return ::Regexp.last_match(1) + .strip + .downcase + .gsub(/[^a-z0-9\s-]/, "") + .gsub(/\s+/, "-") + end + + "github-prompt" + end + + # Normalize GitHub model names + def normalize_github_model(model) + return nil unless model.present? + + model_str = model.to_s.strip + + # GitHub uses simplified model names + case model_str.downcase + when "gpt-4o", "gpt4o" + "openai/gpt-4o" + when "gpt-4", "gpt4" + "openai/gpt-4" + when "gpt-3.5-turbo", "gpt35turbo" + "openai/gpt-3.5-turbo" + when /\Aclaude/ + "anthropic/#{model_str.downcase}" + else + normalize_model(model_str) + end + end + + # Parse GitHub tool references + def parse_github_tools(tools_data) + return [] unless tools_data.is_a?(Array) + + tools_data.map do |tool| + tool_name = tool.to_s + Tool.new(ref: "github://#{tool_name}") + end + end + + # Extract GitHub-specific extensions + def extract_github_extensions(frontmatter) + { + agent: frontmatter["agent"], + mode: frontmatter["mode"], + variables: extract_variables(frontmatter), + # Preserve any other GitHub-specific fields + original_tools: frontmatter["tools"] + }.compact + end + + # Extract variable definitions + def extract_variables(frontmatter) + variables = {} + + # GitHub prompts can define input variables + frontmatter.each do |key, value| + next unless key.to_s.start_with?("input:") + var_name = key.to_s.sub("input:", "") + variables[var_name] = value + end + + variables.presence + end + end + end + + # Register the parser + ParserRegistry.register(:github_prompt, GitHubPromptParser) + end + end +end diff --git a/lib/solid_agent/agent_manifest/picoschema.rb b/lib/solid_agent/agent_manifest/picoschema.rb new file mode 100644 index 0000000..8745dd7 --- /dev/null +++ b/lib/solid_agent/agent_manifest/picoschema.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + # Picoschema provides bidirectional conversion between Picoschema + # (a compact, YAML-optimized schema format from Dotprompt) and JSON Schema. + # + # Picoschema is designed for human readability while JSON Schema provides + # full validation capabilities. + # + # @example Picoschema syntax + # # Simple types + # query: string, the search query + # limit?: integer # optional field + # + # # Enums + # status: string(draft, published, archived) + # + # # Arrays + # tags: [string] + # + # # Nested objects + # author: object + # name: string + # email: string + # + # # Array of objects + # comments: [object] + # author: string + # text: string + # + class Picoschema + SCALAR_TYPES = %w[string integer number boolean any].freeze + + class << self + # Convert Picoschema to JSON Schema + # + # @param picoschema [Hash] Picoschema definition + # @return [Hash] JSON Schema object + def to_json_schema(picoschema) + return { "type" => "object", "properties" => {} } if picoschema.nil? + return picoschema if json_schema?(picoschema) + + parse_object(picoschema) + end + + # Convert JSON Schema to Picoschema + # + # @param json_schema [Hash] JSON Schema object + # @return [Hash] Picoschema definition + def from_json_schema(json_schema) + return {} if json_schema.nil? + return json_schema unless json_schema?(json_schema) + + properties = json_schema["properties"] || json_schema[:properties] || {} + required = json_schema["required"] || json_schema[:required] || [] + + convert_properties(properties, required) + end + + # Parse a single type string (e.g., "string, description") + # + # @param type_str [String] Type definition string + # @return [Hash] JSON Schema for the field + def parse_type_string(type_str) + return { "type" => "any" } if type_str.nil? || type_str.strip == "any" + + str = type_str.to_s.strip + + # Handle enum types first: string(a, b, c), description + # Need to split after the closing paren for enum types + if str =~ /\A(\w+\([^)]+\))(,\s*(.+))?\z/ + type_part = ::Regexp.last_match(1).strip + description = ::Regexp.last_match(3)&.strip + else + # Normal split for non-enum types + parts = str.split(",", 2) + type_part = parts[0].strip + description = parts[1]&.strip + end + + result = parse_type_part(type_part) + result["description"] = description if description.present? + result + end + + private + + # Check if a hash looks like JSON Schema + def json_schema?(schema) + return false unless schema.is_a?(Hash) + + schema.key?("type") || schema.key?(:type) || + schema.key?("properties") || schema.key?(:properties) || + schema.key?("$schema") || schema.key?(:"$schema") + end + + # Parse an object schema (hash of field definitions) + def parse_object(schema_hash) + properties = {} + required = [] + + schema_hash.each do |key, value| + field_name, optional = parse_field_name(key.to_s) + field_schema = parse_field_value(value) + + properties[field_name] = field_schema + required << field_name unless optional + end + + result = { "type" => "object", "properties" => properties } + result["required"] = required if required.any? + result + end + + # Parse field name, detecting optional marker + def parse_field_name(key) + if key.end_with?("?") + [key.chomp("?"), true] # optional + else + [key, false] # required + end + end + + # Parse a field value (could be type string, hash, or array) + def parse_field_value(value) + case value + when String + parse_type_string(value) + when Hash + # Nested object or already parsed schema + if json_schema?(value) + value + else + parse_object(value) + end + when Array + parse_array_type(value) + else + { "type" => "any" } + end + end + + # Parse array type definition + def parse_array_type(array_value) + if array_value.empty? + { "type" => "array", "items" => {} } + elsif array_value.first.is_a?(Hash) + # Array of objects with nested schema + { "type" => "array", "items" => parse_object(array_value.first) } + else + # Array of scalar type + { "type" => "array", "items" => parse_type_string(array_value.first.to_s) } + end + end + + # Parse the type part (before the comma) + def parse_type_part(type_part) + # Handle array shorthand: [string] or [object] + if type_part.start_with?("[") && type_part.end_with?("]") + inner = type_part[1..-2].strip + if inner == "object" + return { "type" => "array", "items" => { "type" => "object" } } + else + return { "type" => "array", "items" => parse_type_part(inner) } + end + end + + # Handle enum: string(option1, option2, option3) + if type_part =~ /\A(\w+)\((.+)\)\z/ + base_type = ::Regexp.last_match(1) + enum_str = ::Regexp.last_match(2) + enum_values = parse_enum_values(enum_str) + return { "type" => base_type, "enum" => enum_values } + end + + # Handle nested object reference + if type_part == "object" + return { "type" => "object" } + end + + # Handle scalar types + if SCALAR_TYPES.include?(type_part.downcase) + return { "type" => type_part.downcase } + end + + # Unknown type - preserve as-is + { "type" => type_part } + end + + # Parse enum values from string like "option1, option2, option3" + def parse_enum_values(enum_str) + enum_str.split(",").map do |val| + val = val.strip + # Remove quotes if present + val = val.gsub(/\A["']|["']\z/, "") + val + end + end + + # Convert JSON Schema properties to Picoschema format + def convert_properties(properties, required_fields) + result = {} + + properties.each do |name, schema| + optional = !required_fields.include?(name.to_s) + key = optional ? "#{name}?" : name.to_s + result[key] = convert_schema_to_pico(schema) + end + + result + end + + # Convert a single JSON Schema field to Picoschema string + def convert_schema_to_pico(schema) + type = schema["type"] || schema[:type] + desc = schema["description"] || schema[:description] + enum_values = schema["enum"] || schema[:enum] + + # Handle array types + if type == "array" + items = schema["items"] || schema[:items] || {} + items_type = items["type"] || items[:type] || "any" + if items_type == "object" + # Complex - return as hash + return [convert_properties(items["properties"] || {}, items["required"] || [])] + else + return "[#{items_type}]" + end + end + + # Handle object types + if type == "object" && (schema["properties"] || schema[:properties]) + return convert_properties( + schema["properties"] || schema[:properties], + schema["required"] || schema[:required] || [] + ) + end + + # Build picoschema string + pico = type.to_s + if enum_values&.any? + pico = "#{type}(#{enum_values.join(", ")})" + end + if desc.present? + pico = "#{pico}, #{desc}" + end + + pico + end + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/registry/auth.rb b/lib/solid_agent/agent_manifest/registry/auth.rb new file mode 100644 index 0000000..0c044eb --- /dev/null +++ b/lib/solid_agent/agent_manifest/registry/auth.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + module Registry + # Auth handles token management for the ActiveAgents registry. + # + # Tokens are stored in ~/.activeagent/token by default. + # Can also be configured via environment variable. + # + # @example Check if logged in + # Registry::Auth.logged_in? + # + # @example Get current token + # token = Registry::Auth.token + # + # @example Save a token + # Registry::Auth.save_token("aa_live_xxx") + # + class Auth + TOKEN_PATH = File.expand_path("~/.activeagent/token") + TOKEN_ENV_VAR = "ACTIVEAGENT_TOKEN" + + class << self + # Get the current authentication token + # + # Checks in order: + # 1. Environment variable (ACTIVEAGENT_TOKEN) + # 2. Token file (~/.activeagent/token) + # + # @return [String, nil] The token or nil if not authenticated + def token + # Check environment variable first + env_token = ENV[TOKEN_ENV_VAR] + return env_token if env_token.present? + + # Check token file + return nil unless File.exist?(TOKEN_PATH) + + File.read(TOKEN_PATH).strip.presence + end + + # Save a token to the token file + # + # @param new_token [String] The token to save + # @return [Boolean] true if saved successfully + def save_token(new_token) + # Ensure directory exists + FileUtils.mkdir_p(File.dirname(TOKEN_PATH)) + + # Write token with secure permissions + File.write(TOKEN_PATH, new_token.strip) + File.chmod(0o600, TOKEN_PATH) + + true + rescue StandardError => e + raise RegistryError, "Failed to save token: #{e.message}" + end + + # Clear the saved token + # + # @return [Boolean] true if cleared successfully + def clear_token + return true unless File.exist?(TOKEN_PATH) + + File.delete(TOKEN_PATH) + true + rescue StandardError => e + raise RegistryError, "Failed to clear token: #{e.message}" + end + + # Check if user is logged in + # + # @return [Boolean] + def logged_in? + token.present? + end + + # Require authentication, raising if not logged in + # + # @raise [RegistryError] if not authenticated + # @return [String] The token + def require_token! + t = token + raise RegistryError, "Not authenticated. Run 'activeagent login' or set #{TOKEN_ENV_VAR}" unless t + + t + end + + # Get authorization header for API requests + # + # @return [Hash] Authorization header + def auth_header + t = token + return {} unless t + + { "Authorization" => "Bearer #{t}" } + end + end + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/registry/client.rb b/lib/solid_agent/agent_manifest/registry/client.rb new file mode 100644 index 0000000..7aa24cb --- /dev/null +++ b/lib/solid_agent/agent_manifest/registry/client.rb @@ -0,0 +1,384 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" +require "json" +require "fileutils" + +module SolidAgent + module AgentManifest + module Registry + # Client provides HTTP API access to the ActiveAgents registry. + # + # @example Search for agents + # client = Registry::Client.new + # results = client.search(q: "research", tags: ["assistant"]) + # + # @example Download an agent + # client.download("@anthropic/research-assistant", path: "./agents/") + # + # @example Publish an agent + # client = Registry::Client.new(token: "aa_live_xxx") + # client.publish("./research-assistant.agent.md") + # + class Client + BASE_URL = "https://api.activeagents.ai/v1" + + attr_reader :base_url, :token + + # Initialize a new client + # + # @param token [String, nil] Auth token (uses Auth.token if nil) + # @param base_url [String] API base URL + def initialize(token: nil, base_url: BASE_URL) + @token = token || Auth.token + @base_url = base_url + end + + # === Discovery Methods === + + # Search for agents + # + # @param q [String, nil] Search query + # @param tags [Array, nil] Filter by tags + # @param framework [String, nil] Filter by framework (e.g., "activeagent", "crewai") + # @param author [String, nil] Filter by author + # @param sort [String] Sort order ("popular", "recent", "name") + # @param page [Integer] Page number + # @param per_page [Integer] Results per page + # @return [Hash] Search results with :agents, :total, :page keys + def search(q: nil, tags: nil, framework: nil, author: nil, sort: "popular", page: 1, per_page: 20) + params = { + q: q, + tags: tags&.join(","), + framework: framework, + author: author, + sort: sort, + page: page, + per_page: per_page + }.compact + + get("/agents", params) + end + + # Get an agent by name + # + # @param name [String] Agent name (e.g., "@anthropic/research-assistant") + # @param version [String, nil] Specific version (latest if nil) + # @return [Hash] Agent metadata + def get(name, version: nil) + path = version ? "/agents/#{encode_name(name)}/versions/#{version}" : "/agents/#{encode_name(name)}" + get_request(path) + end + + # List all versions of an agent + # + # @param name [String] Agent name + # @return [Array] Version list + def versions(name) + get_request("/agents/#{encode_name(name)}/versions") + end + + # === Download Methods === + + # Download an agent manifest + # + # @param name [String] Agent name + # @param version [String, nil] Specific version + # @param path [String, nil] Download directory (current dir if nil) + # @param format [Symbol] Output format (:agent_md, :dotprompt, etc.) + # @return [String] Path to downloaded file + def download(name, version: nil, path: nil, format: :agent_md) + # Get agent metadata + agent = get(name, version: version) + + # Determine output path + output_dir = path || Dir.pwd + FileUtils.mkdir_p(output_dir) + + filename = agent_filename(agent, format) + output_path = File.join(output_dir, filename) + + # Download content + content_url = agent["download_url"] || agent.dig("links", "download") + if content_url + content = fetch_content(content_url) + else + # Fetch from content endpoint + content = get_request("/agents/#{encode_name(name)}/content", accept: "text/markdown") + end + + # Convert format if needed + source_format = agent["format"]&.to_sym || :agent_md + if format != source_format + manifest = ParserRegistry.parse_string(content, format: source_format) + content = ExporterRegistry.export(manifest, format) + end + + File.write(output_path, content, encoding: "UTF-8") + output_path + end + + # Download a specific file from an agent package + # + # @param name [String] Agent name + # @param file_path [String] Path within the package + # @param version [String, nil] Specific version + # @return [String] File content + def download_file(name, file_path, version: nil) + path = "/agents/#{encode_name(name)}/files/#{file_path}" + path += "?version=#{version}" if version + get_request(path, accept: "*/*") + end + + # === Publishing Methods === + + # Publish an agent manifest + # + # @param path [String] Path to manifest file + # @param tag [String] Version tag ("latest", "beta", etc.) + # @param scope [String, nil] Organization scope + # @param access [String] Access level ("public", "private") + # @return [Hash] Published agent info + def publish(path, tag: "latest", scope: nil, access: "public") + require_auth! + + manifest = AgentManifest.parse(path) + AgentManifest.validate!(manifest, strict: true) + + content = File.read(path, encoding: "UTF-8") + format = ParserRegistry.detect_format(path) + + body = { + name: scope ? "#{scope}/#{manifest.name}" : manifest.name, + version: manifest.version, + tag: tag, + access: access, + format: format, + content: content, + metadata: { + description: manifest.description, + tags: manifest.tags, + model: manifest.model, + author: manifest.author, + license: manifest.license, + repository: manifest.repository + } + } + + post("/agents", body) + end + + # Deprecate a version + # + # @param name [String] Agent name + # @param version [String] Version to deprecate + # @param message [String] Deprecation message + # @return [Hash] Updated version info + def deprecate(name, version, message:) + require_auth! + patch("/agents/#{encode_name(name)}/versions/#{version}", { deprecated: true, deprecation_message: message }) + end + + # Unpublish a version + # + # @param name [String] Agent name + # @param version [String] Version to remove + # @return [Boolean] + def unpublish(name, version) + require_auth! + delete("/agents/#{encode_name(name)}/versions/#{version}") + true + end + + # === User Actions === + + # Star an agent + # + # @param name [String] Agent name + # @return [Boolean] + def star(name) + require_auth! + post("/agents/#{encode_name(name)}/star", {}) + true + end + + # Unstar an agent + # + # @param name [String] Agent name + # @return [Boolean] + def unstar(name) + require_auth! + delete("/agents/#{encode_name(name)}/star") + true + end + + # Fork an agent + # + # @param name [String] Source agent name + # @param new_name [String] Name for the fork + # @param scope [String, nil] Organization scope + # @return [Hash] Forked agent info + def fork(name, new_name:, scope: nil) + require_auth! + post("/agents/#{encode_name(name)}/fork", { new_name: new_name, scope: scope }) + end + + # === Sandbox Methods === + + # Run an agent in the sandbox + # + # @param name [String] Agent name + # @param input [Hash] Input parameters + # @param model [String, nil] Override model + # @param stream [Boolean] Enable streaming + # @return [Hash] Run result or job info + def run(name, input:, model: nil, stream: false) + require_auth! + body = { input: input, model: model, stream: stream }.compact + post("/agents/#{encode_name(name)}/run", body) + end + + # Get status of a sandbox run + # + # @param run_id [String] Run ID + # @return [Hash] Run status + def run_status(run_id) + require_auth! + get_request("/runs/#{run_id}") + end + + # === Account Methods === + + # Get current user info + # + # @return [Hash] User info + def me + require_auth! + get_request("/me") + end + + # Get user's starred agents + # + # @return [Array] Starred agents + def my_stars + require_auth! + get_request("/me/stars") + end + + # Get user's published agents + # + # @return [Array] Published agents + def my_agents + require_auth! + get_request("/me/agents") + end + + private + + def require_auth! + raise RegistryError, "Authentication required" unless token + end + + def encode_name(name) + # Handle scoped names: @scope/name -> @scope%2Fname + URI.encode_www_form_component(name) + end + + def agent_filename(agent, format) + name = agent["name"].to_s.split("/").last + extension = case format + when :agent_md then ".agent.md" + when :dotprompt then ".prompt" + when :crewai then ".yaml" + when :github_prompt then ".prompt.md" + else ".agent.md" + end + "#{name}#{extension}" + end + + def get_request(path, params = {}, accept: "application/json") + uri = build_uri(path, params.except(:accept)) + request = Net::HTTP::Get.new(uri) + request["Accept"] = accept + execute(request) + end + + def post(path, body) + uri = build_uri(path) + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/json" + request.body = body.to_json + execute(request) + end + + def patch(path, body) + uri = build_uri(path) + request = Net::HTTP::Patch.new(uri) + request["Content-Type"] = "application/json" + request.body = body.to_json + execute(request) + end + + def delete(path) + uri = build_uri(path) + request = Net::HTTP::Delete.new(uri) + execute(request) + end + + def build_uri(path, params = {}) + uri = URI.parse("#{base_url}#{path}") + uri.query = URI.encode_www_form(params) if params.any? + uri + end + + def execute(request) + # Add auth header if available + if token + request["Authorization"] = "Bearer #{token}" + end + + request["User-Agent"] = "SolidAgent/#{SolidAgent::VERSION}" + + uri = request.uri + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = 10 + http.read_timeout = 30 + + response = http.request(request) + + case response + when Net::HTTPSuccess + parse_response(response) + when Net::HTTPUnauthorized + raise RegistryError, "Authentication failed. Please login again." + when Net::HTTPForbidden + raise RegistryError, "Access denied" + when Net::HTTPNotFound + raise RegistryError, "Resource not found" + when Net::HTTPUnprocessableEntity + error_body = parse_response(response) rescue {} + message = error_body["error"] || error_body["message"] || "Validation failed" + raise ValidationError, message + else + error_body = parse_response(response) rescue {} + message = error_body["error"] || error_body["message"] || "Request failed: #{response.code}" + raise RegistryError, message + end + end + + def parse_response(response) + return response.body unless response["Content-Type"]&.include?("application/json") + + JSON.parse(response.body) + end + + def fetch_content(url) + uri = URI.parse(url) + Net::HTTP.get(uri) + end + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/resource.rb b/lib/solid_agent/agent_manifest/resource.rb new file mode 100644 index 0000000..9d69c7d --- /dev/null +++ b/lib/solid_agent/agent_manifest/resource.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + # Resource represents an external data source that an agent can access. + # + # Resources follow MCP (Model Context Protocol) conventions and can represent + # files, APIs, databases, or other data sources. + # + # @example File resource + # resource = Resource.new( + # name: "company_docs", + # description: "Internal company documentation", + # uri: "file:///docs/**/*.md", + # mime_type: "text/markdown" + # ) + # + # @example API resource + # resource = Resource.new( + # name: "api_spec", + # description: "OpenAPI specification", + # uri: "https://api.example.com/openapi.json", + # mime_type: "application/json" + # ) + # + class Resource + # @return [String] Resource identifier + attr_accessor :name + + # @return [String, nil] Human-readable description + attr_accessor :description + + # @return [String] URI pattern or URL + attr_accessor :uri + + # @return [String, nil] MIME type of the resource content + attr_accessor :mime_type + + def initialize(attributes = {}) + attributes.each do |key, value| + setter = "#{key}=" + send(setter, value) if respond_to?(setter) + end + end + + # Convert to hash representation + # + # @return [Hash] + def to_h + { + name: name, + description: description, + uri: uri, + mimeType: mime_type + }.compact + end + + # Create from hash (flexible key formats) + # + # @param data [Hash] + # @return [Resource] + def self.from_hash(data) + new( + name: data["name"] || data[:name], + description: data["description"] || data[:description], + uri: data["uri"] || data[:uri], + mime_type: data["mimeType"] || data["mime_type"] || data[:mime_type] || data[:mimeType] + ) + end + + # Check if this is a file resource + # + # @return [Boolean] + def file? + uri&.start_with?("file://") + end + + # Check if this is an HTTP resource + # + # @return [Boolean] + def http? + uri&.match?(%r{\Ahttps?://}) + end + + # Check if resource is valid + # + # @return [Boolean] + def valid? + name.present? && uri.present? + end + + # Validation errors + # + # @return [Array] + def validation_errors + errors = [] + errors << "Resource must have a name" if name.blank? + errors << "Resource must have a uri" if uri.blank? + errors + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/tool.rb b/lib/solid_agent/agent_manifest/tool.rb new file mode 100644 index 0000000..5ed5159 --- /dev/null +++ b/lib/solid_agent/agent_manifest/tool.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + # Tool represents a function/tool that an agent can invoke. + # + # Tools follow the MCP (Model Context Protocol) conventions for maximum + # portability across different LLM providers and frameworks. + # + # @example Inline tool definition + # tool = Tool.new( + # name: "search", + # description: "Search the web for information", + # input_schema: { + # "type" => "object", + # "properties" => { + # "query" => { "type" => "string", "description" => "Search query" } + # }, + # "required" => ["query"] + # } + # ) + # + # @example Tool reference + # tool = Tool.new(ref: "@activeagents/web-tools/search") + # + class Tool + # @return [String, nil] Tool name (required for inline definitions) + attr_accessor :name + + # @return [String, nil] Human-readable description + attr_accessor :description + + # @return [Hash, nil] JSON Schema for tool input parameters + attr_accessor :input_schema + + # @return [String, nil] Reference to external tool (e.g., "$ref" or package reference) + attr_accessor :ref + + def initialize(attributes = {}) + attributes.each do |key, value| + setter = "#{key}=" + send(setter, value) if respond_to?(setter) + end + end + + # Check if this tool is a reference to an external tool + # + # @return [Boolean] + def reference? + ref.present? + end + + # Check if this tool has an inline definition + # + # @return [Boolean] + def inline? + !reference? && name.present? + end + + # Convert to hash representation + # + # @return [Hash] + def to_h + if reference? + { "$ref" => ref } + else + { + name: name, + description: description, + inputSchema: input_schema + }.compact + end + end + + # Convert to MCP-compatible JSON string + # + # @return [String] JSON representation following MCP tool format + def to_mcp_json + { + name: name, + description: description, + inputSchema: input_schema + }.compact.to_json + end + + # Convert to HasTools::ToolBuilder compatible schema + # + # This format is compatible with OpenAI's function calling API + # and SolidAgent's existing HasTools concern. + # + # @return [Hash] + def to_tool_builder_schema + { + type: "function", + name: name, + description: description, + parameters: input_schema || { type: "object", properties: {} } + } + end + + # Create a Tool from a HasTools::ToolBuilder schema + # + # @param schema [Hash] Schema from ToolBuilder + # @return [Tool] + def self.from_tool_builder(schema) + new( + name: schema[:name] || schema["name"], + description: schema[:description] || schema["description"], + input_schema: schema[:parameters] || schema["parameters"] + ) + end + + # Create a Tool from a hash (flexible key formats) + # + # @param data [Hash] Tool data with various key formats + # @return [Tool] + def self.from_hash(data) + return new(ref: data["$ref"]) if data["$ref"] + + new( + name: data["name"] || data[:name], + description: data["description"] || data[:description], + input_schema: data["inputSchema"] || data["input_schema"] || data[:input_schema] || data[:inputSchema] + ) + end + + # Get list of required parameters + # + # @return [Array] + def required_parameters + input_schema&.dig("required") || input_schema&.dig(:required) || [] + end + + # Get all parameter names + # + # @return [Array] + def parameter_names + properties = input_schema&.dig("properties") || input_schema&.dig(:properties) || {} + properties.keys.map(&:to_s) + end + + # Check if tool is valid (has required fields) + # + # @return [Boolean] + def valid? + reference? || name.present? + end + + # Validation errors + # + # @return [Array] + def validation_errors + errors = [] + errors << "Tool must have a name or $ref" unless valid? + errors << "Tool description is recommended" if inline? && description.blank? + errors + end + end + end +end diff --git a/lib/solid_agent/agent_manifest/validator.rb b/lib/solid_agent/agent_manifest/validator.rb new file mode 100644 index 0000000..ff2207d --- /dev/null +++ b/lib/solid_agent/agent_manifest/validator.rb @@ -0,0 +1,368 @@ +# frozen_string_literal: true + +module SolidAgent + module AgentManifest + # Validator provides comprehensive validation for Manifests. + # + # Goes beyond ActiveModel validations to check semantic correctness, + # tool definitions, schema validity, and cross-field consistency. + # + # @example Validate a manifest + # errors = Validator.validate(manifest) + # if errors.empty? + # puts "Manifest is valid!" + # else + # errors.each { |e| puts "Error: #{e}" } + # end + # + # @example Check validity + # Validator.valid?(manifest) # => true/false + # + # @example Validate and raise + # Validator.validate!(manifest) # raises ValidationError if invalid + # + class Validator + # Known model providers for validation + KNOWN_PROVIDERS = %w[anthropic openai google meta cohere mistral].freeze + + # Reserved names that cannot be used + RESERVED_NAMES = %w[agent manifest config settings default system].freeze + + class << self + # Validate a manifest and return array of error messages + # + # @param manifest [Manifest] Manifest to validate + # @param strict [Boolean] Enable strict validation + # @return [Array] Error messages (empty if valid) + def validate(manifest, strict: false) + errors = [] + + errors.concat(validate_meta(manifest)) + errors.concat(validate_model(manifest)) + errors.concat(validate_tools(manifest)) + errors.concat(validate_resources(manifest)) + errors.concat(validate_schemas(manifest)) + errors.concat(validate_extensions(manifest)) + errors.concat(validate_content(manifest)) + + errors.concat(validate_strict(manifest)) if strict + + errors + end + + # Check if a manifest is valid + # + # @param manifest [Manifest] Manifest to validate + # @param strict [Boolean] Enable strict validation + # @return [Boolean] + def valid?(manifest, strict: false) + validate(manifest, strict: strict).empty? + end + + # Validate and raise if invalid + # + # @param manifest [Manifest] Manifest to validate + # @param strict [Boolean] Enable strict validation + # @raise [ValidationError] if manifest is invalid + # @return [Manifest] The validated manifest + def validate!(manifest, strict: false) + errors = validate(manifest, strict: strict) + raise ValidationError, "Invalid manifest: #{errors.join('; ')}" if errors.any? + + manifest + end + + private + + # Validate meta fields + def validate_meta(manifest) + errors = [] + + # Name is required + if manifest.name.blank? + errors << "name is required" + else + # Name format + unless manifest.name =~ /\A[a-z][a-z0-9\-]*\z/ + errors << "name must start with lowercase letter and contain only lowercase letters, numbers, and hyphens" + end + + # Name length + if manifest.name.length > 100 + errors << "name must be 100 characters or less" + end + + # Reserved names + if RESERVED_NAMES.include?(manifest.name) + errors << "name '#{manifest.name}' is reserved" + end + end + + # Version format + if manifest.version.present? + unless manifest.version =~ /\A\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?\z/ + errors << "version must follow semantic versioning (e.g., 1.0.0, 1.0.0-beta.1)" + end + end + + # Tags + if manifest.tags.is_a?(Array) + manifest.tags.each do |tag| + unless tag =~ /\A[a-z][a-z0-9\-]*\z/ + errors << "tag '#{tag}' must be lowercase with hyphens only" + end + end + end + + # Repository URL + if manifest.repository.present? + unless manifest.repository =~ %r{\Ahttps?://} + errors << "repository must be a valid URL" + end + end + + errors + end + + # Validate model configuration + def validate_model(manifest) + errors = [] + + return errors if manifest.model.blank? + + # Model format - should be provider/model + if manifest.model.include?("/") + provider, model_name = manifest.model.split("/", 2) + + # Warn about unknown providers (not an error) + unless KNOWN_PROVIDERS.include?(provider) + # This is just informational, not an error + end + + if model_name.blank? + errors << "model identifier must include model name after provider" + end + end + + # Config validation + if manifest.config.present? + # Temperature range + temp = manifest.config[:temperature] || manifest.config["temperature"] + if temp && (temp.to_f < 0 || temp.to_f > 2) + errors << "temperature must be between 0 and 2" + end + + # Max tokens + max_tokens = manifest.config[:max_tokens] || manifest.config["max_tokens"] + if max_tokens && max_tokens.to_i <= 0 + errors << "max_tokens must be a positive integer" + end + + # Top P + top_p = manifest.config[:top_p] || manifest.config["top_p"] + if top_p && (top_p.to_f < 0 || top_p.to_f > 1) + errors << "top_p must be between 0 and 1" + end + end + + errors + end + + # Validate tool definitions + def validate_tools(manifest) + errors = [] + + return errors unless manifest.tools.is_a?(Array) + + tool_names = Set.new + + manifest.tools.each_with_index do |tool, index| + prefix = "tools[#{index}]" + + if tool.reference? + # Reference validation + if tool.ref.blank? + errors << "#{prefix}: $ref cannot be blank" + end + else + # Inline tool validation + if tool.name.blank? + errors << "#{prefix}: name is required for inline tools" + else + # Check for duplicates + if tool_names.include?(tool.name) + errors << "#{prefix}: duplicate tool name '#{tool.name}'" + end + tool_names.add(tool.name) + + # Name format + unless tool.name =~ /\A[a-zA-Z][a-zA-Z0-9_]*\z/ + errors << "#{prefix}: name '#{tool.name}' must start with letter and contain only alphanumeric and underscore" + end + end + + # Input schema validation + if tool.input_schema.present? + unless tool.input_schema.is_a?(Hash) + errors << "#{prefix}: inputSchema must be an object" + else + unless tool.input_schema["type"] == "object" + errors << "#{prefix}: inputSchema type should be 'object'" + end + end + end + end + end + + errors + end + + # Validate resource definitions + def validate_resources(manifest) + errors = [] + + return errors unless manifest.resources.is_a?(Array) + + manifest.resources.each_with_index do |resource, index| + prefix = "resources[#{index}]" + + if resource.uri.blank? + errors << "#{prefix}: uri is required" + else + # Basic URI format check + unless resource.uri =~ %r{\A[a-z][a-z0-9+.-]*://}i + errors << "#{prefix}: uri must be a valid URI with scheme" + end + end + + # MIME type format + if resource.mime_type.present? + unless resource.mime_type =~ %r{\A[a-z]+/[a-z0-9.+-]+\z}i + errors << "#{prefix}: mimeType format is invalid" + end + end + end + + errors + end + + # Validate schemas + def validate_schemas(manifest) + errors = [] + + # Input schema + if manifest.input_schema.present? + begin + manifest.input_schema.to_json_schema + rescue StandardError => e + errors << "input schema is invalid: #{e.message}" + end + end + + # Output schema + if manifest.output_schema.present? + unless manifest.output_schema.is_a?(Hash) + errors << "output schema must be an object" + end + end + + errors + end + + # Validate framework extensions + def validate_extensions(manifest) + errors = [] + + return errors unless manifest.extensions.is_a?(Hash) + + # ActiveAgent extensions + aa_ext = manifest.extensions[:activeagent] + if aa_ext.is_a?(Hash) + # Class name format + if aa_ext[:class_name].present? + unless aa_ext[:class_name] =~ /\A[A-Z][a-zA-Z0-9]*Agent\z/ + errors << "activeagent.class_name should follow Rails naming convention (e.g., MyAgent)" + end + end + + # Concerns validation + if aa_ext[:concerns].is_a?(Array) + aa_ext[:concerns].each do |concern| + concern_name = concern.is_a?(Hash) ? concern.keys.first : concern + unless concern_name.to_s =~ /\Ahas_[a-z_]+\z/ + errors << "activeagent.concerns: '#{concern_name}' should follow has_* naming pattern" + end + end + end + end + + errors + end + + # Validate content (instructions, template) + def validate_content(manifest) + errors = [] + + # Must have some form of content + if manifest.instructions.blank? && manifest.template.blank? + errors << "manifest must have instructions or template content" + end + + # Template syntax check (basic Liquid validation) + if manifest.template.present? + # Check for unclosed tags + open_tags = manifest.template.scan(/\{%\s*(\w+)/).flatten + close_tags = manifest.template.scan(/\{%\s*end(\w+)/).flatten + + # Simple balance check for common tags + %w[if for unless case].each do |tag| + opens = open_tags.count(tag) + closes = close_tags.count(tag) + if opens != closes + errors << "template has unbalanced {% #{tag} %} tags (#{opens} opens, #{closes} closes)" + end + end + + # Check for unclosed variable tags + if manifest.template.count("{{") != manifest.template.count("}}") + errors << "template has unclosed {{ }} variable tags" + end + end + + errors + end + + # Strict validation (additional checks) + def validate_strict(manifest) + errors = [] + + # Description required in strict mode + if manifest.description.blank? + errors << "description is required in strict mode" + end + + # Model required in strict mode + if manifest.model.blank? + errors << "model is required in strict mode" + end + + # All tools must have descriptions + manifest.tools&.each_with_index do |tool, index| + next if tool.reference? + + if tool.description.blank? + errors << "tools[#{index}]: description is required in strict mode" + end + end + + # Version required in strict mode + if manifest.version.blank? + errors << "version is required in strict mode" + end + + errors + end + end + end + end +end diff --git a/lib/solid_agent/has_context.rb b/lib/solid_agent/has_context.rb index 7dac9ea..6ea89a1 100644 --- a/lib/solid_agent/has_context.rb +++ b/lib/solid_agent/has_context.rb @@ -6,10 +6,10 @@ # to persist its prompt context, messages, and generation results to the database. # It works similarly to ActiveRecord associations, allowing custom naming. # -# @example Basic usage with auto-context (contextable inferred from params) +# @example Basic usage with auto-context (contextual inferred from params) # class WritingAssistantAgent < ApplicationAgent # include SolidAgent::HasContext -# has_context contextable: :document # Auto-creates context from params[:document] +# has_context contextual: :document # Auto-creates context from params[:document] # # def improve # prompt # Context automatically created before prompt @@ -19,7 +19,7 @@ # @example Named context with auto-creation # class ChatAgent < ApplicationAgent # include SolidAgent::HasContext -# has_context :conversation, contextable: :user # Auto-loads/creates from params[:user] +# has_context :conversation, contextual: :user # Auto-loads/creates from params[:user] # # def chat # add_conversation_user_message(params[:message]) @@ -27,10 +27,10 @@ # end # end # -# @example Manual context management (contextable: false) +# @example Manual context management (contextual: false) # class ResearchAgent < ApplicationAgent # include SolidAgent::HasContext -# has_context :research_session, contextable: false +# has_context :research_session, contextual: false # # def research # create_research_session(contextable: params[:project]) # Manual creation @@ -38,11 +38,11 @@ # end # end # -# @example Multiple contexts with different contextables +# @example Multiple contexts with different contextual params # class MultiModalAgent < ApplicationAgent # include SolidAgent::HasContext -# has_context :conversation, contextable: :user # Auto from params[:user] -# has_context :analysis, contextable: :document # Auto from params[:document] +# has_context :conversation, contextual: :user # Auto from params[:user] +# has_context :analysis, contextual: :document # Auto from params[:document] # # def analyze # prompt # Both contexts auto-created @@ -79,26 +79,26 @@ module HasContext # # @param auto_save [Boolean] Automatically save generation results (default: true) # - # @param contextable [Symbol, false, nil] Param key for auto-context creation - # - Symbol: Auto-load/create context using params[contextable] (e.g., :user, :document) + # @param contextual [Symbol, false, nil] Param key for auto-context creation + # - Symbol: Auto-load/create context using params[contextual] (e.g., :user, :document) # - false: Disable auto-context, require manual create_* or load_* calls # - nil: Auto-create context without a contextable (anonymous context) # # @example Auto-context from params - # has_context :conversation, contextable: :user + # has_context :conversation, contextual: :user # # @example Manual context management - # has_context :session, contextable: false + # has_context :session, contextual: false # # @example Fully customized # has_context :session, # class_name: "ChatSession", # message_class: "ChatMessage", # generation_class: "ChatGeneration", - # contextable: :chat_user, + # contextual: :chat_user, # auto_save: false # - def has_context(name = nil, class_name: nil, message_class: nil, generation_class: nil, auto_save: true, contextable: nil) + def has_context(name = nil, class_name: nil, message_class: nil, generation_class: nil, auto_save: true, contextual: nil) # Normalize name context_name = normalize_context_name(name) @@ -111,7 +111,7 @@ def has_context(name = nil, class_name: nil, message_class: nil, generation_clas message_class: message_class || inferred_classes[:message], generation_class: generation_class || inferred_classes[:generation], auto_save: auto_save, - contextable: contextable + contextual: contextual } # Store configuration @@ -129,10 +129,10 @@ def has_context(name = nil, class_name: nil, message_class: nil, generation_clas around_generation :capture_and_persist_generation end - # Add auto-context callback if contextable is not explicitly false - if contextable != false + # Add auto-context callback if contextual is not explicitly false + if contextual != false after_prompt :"ensure_#{context_name}_exists" - define_auto_context_method(context_name, contextable) + define_auto_context_method(context_name, contextual) end end @@ -177,17 +177,17 @@ def define_context_accessor(context_name) attr_accessor context_name end - def define_auto_context_method(context_name, contextable_key) + def define_auto_context_method(context_name, contextual_key) # Define ensure_{name}_exists method that auto-creates context if not present define_method("ensure_#{context_name}_exists") do return if send(context_name).present? config = self.class._context_configs[context_name] - contextable_param = config[:contextable] + contextual_param = config[:contextual] - if contextable_param.is_a?(Symbol) + if contextual_param.is_a?(Symbol) # Load or create with contextable from params - contextable_value = params[contextable_param] + contextable_value = params[contextual_param] send("load_#{context_name}", contextable: contextable_value) else # Create anonymous context (no contextable) diff --git a/test/fixtures/agent_manifest/crewai/agents.yaml b/test/fixtures/agent_manifest/crewai/agents.yaml new file mode 100644 index 0000000..5d69fdb --- /dev/null +++ b/test/fixtures/agent_manifest/crewai/agents.yaml @@ -0,0 +1,30 @@ +research_analyst: + role: > + Senior Research Analyst + goal: > + Uncover cutting-edge developments in AI and data science + backstory: > + You're a seasoned researcher with a knack for uncovering the latest + developments in AI and data science. Known for your ability to find + the most relevant information and present it in a clear, concise manner. + llm: claude-sonnet-4-20250514 + tools: + - SerperDevTool + - WebsiteSearchTool + verbose: true + allow_delegation: false + +reporting_analyst: + role: > + Reporting Analyst + goal: > + Create detailed reports based on data analysis and research findings + backstory: > + You're a meticulous analyst with a keen eye for detail. You're known for + your ability to turn complex data into clear and concise reports, making + it easy for others to understand and act on the information you provide. + llm: gpt-4o + tools: + - FileReadTool + memory: true + cache: true diff --git a/test/fixtures/agent_manifest/dotprompt/basic.prompt b/test/fixtures/agent_manifest/dotprompt/basic.prompt new file mode 100644 index 0000000..f5f6edc --- /dev/null +++ b/test/fixtures/agent_manifest/dotprompt/basic.prompt @@ -0,0 +1,30 @@ +--- +model: googleai/gemini-1.5-pro +temperature: 0.7 +maxOutputTokens: 2048 + +input: + schema: + text: string + language?: string + +output: + format: json + schema: + type: object + properties: + translated: + type: string + confidence: + type: number + +tools: + - detect_language + - translate_text +--- + +Translate the following text to {{ language | default: "English" }}: + +{{ text }} + +Provide your translation in JSON format with the translated text and a confidence score. diff --git a/test/fixtures/agent_manifest/github_prompt/copilot.prompt.md b/test/fixtures/agent_manifest/github_prompt/copilot.prompt.md new file mode 100644 index 0000000..1082aa3 --- /dev/null +++ b/test/fixtures/agent_manifest/github_prompt/copilot.prompt.md @@ -0,0 +1,30 @@ +--- +model: GPT-4o +tools: ['githubRepo', 'search/codebase', 'vscodeAPI'] +description: 'Generate a new React form component with validation' +mode: 'agent' +--- + +# React Form Generator + +Create a React form component for ${input:formName} with the following features: + +- Form validation using ${input:validationLibrary} +- TypeScript types for form data +- Accessible form controls with proper labels +- Error message display +- Submit handler with loading state + +## Component Structure + +The component should follow this pattern: +- Use controlled inputs +- Include proper ARIA attributes +- Handle form submission with async/await +- Show validation errors inline + +## Guidelines + +- Follow the existing project patterns found in the codebase +- Use the project's existing UI component library if available +- Include unit tests for the form validation logic diff --git a/test/fixtures/agent_manifest/valid/full_featured.agent.md b/test/fixtures/agent_manifest/valid/full_featured.agent.md new file mode 100644 index 0000000..d599808 --- /dev/null +++ b/test/fixtures/agent_manifest/valid/full_featured.agent.md @@ -0,0 +1,126 @@ +--- +name: research-assistant +version: 2.1.0 +description: A comprehensive research assistant for academic and professional research +author: ActiveAgents Team +license: MIT +repository: https://github.com/activeagents/research-assistant +tags: + - research + - assistant + - academic + +model: anthropic/claude-sonnet-4-20250514 +config: + temperature: 0.7 + max_tokens: 4096 + +input: + schema: + query: "string, The research query to investigate" + depth?: "string(quick, standard, thorough), Research depth level" + sources?: "[string], Preferred source types" + +output: + format: json + schema: + type: object + properties: + summary: + type: string + sources: + type: array + items: + type: object + properties: + title: + type: string + url: + type: string + confidence: + type: number + +tools: + - name: search_web + description: Search the web for information + inputSchema: + type: object + properties: + query: + type: string + description: Search query + num_results: + type: integer + default: 10 + required: + - query + + - name: fetch_url + description: Fetch content from a URL + inputSchema: + type: object + properties: + url: + type: string + format: uri + required: + - url + +resources: + - name: knowledge_base + uri: file://./knowledge/ + mimeType: application/json + description: Local knowledge base + +activeagent: + class_name: ResearchAssistantAgent + concerns: + - has_context: + contextual: user + - has_tools: [search_web, fetch_url] + +examples: + - input: + query: "What are the latest developments in quantum computing?" + depth: "standard" + output: + summary: "Recent developments include..." + +tests: + - name: basic_search + input: + query: "test query" + expect: + contains: "summary" +--- + +# Research Assistant + +You are a comprehensive research assistant designed to help users +investigate topics thoroughly and provide well-sourced information. + +## Instructions + +When given a research query: + +1. **Understand the Query**: Parse the user's question to identify key concepts +2. **Search for Information**: Use the search_web tool to find relevant sources +3. **Verify Sources**: Cross-reference information across multiple sources +4. **Synthesize Findings**: Compile information into a coherent summary +5. **Cite Sources**: Always include references to your sources + +## Guidelines + +- Prioritize peer-reviewed and authoritative sources +- Acknowledge uncertainty when information is conflicting +- Present multiple perspectives on controversial topics +- Organize information logically with clear structure +- Use the {{ depth }} parameter to adjust thoroughness + +## Template + +Based on your query about "{{ query }}", I will conduct a {{ depth | default: "standard" }} investigation. + +{% if sources %} +I'll focus on these source types: {{ sources | join: ", " }} +{% endif %} diff --git a/test/fixtures/agent_manifest/valid/minimal.agent.md b/test/fixtures/agent_manifest/valid/minimal.agent.md new file mode 100644 index 0000000..0a8ea2a --- /dev/null +++ b/test/fixtures/agent_manifest/valid/minimal.agent.md @@ -0,0 +1,7 @@ +--- +name: minimal-agent +--- + +# Minimal Agent + +A minimal agent for testing. diff --git a/test/fixtures/agent_manifest/valid/with_tools.agent.md b/test/fixtures/agent_manifest/valid/with_tools.agent.md new file mode 100644 index 0000000..7f240fd --- /dev/null +++ b/test/fixtures/agent_manifest/valid/with_tools.agent.md @@ -0,0 +1,42 @@ +--- +name: tool-agent +version: 1.0.0 +description: Agent with multiple tools +model: openai/gpt-4o + +tools: + - name: calculate + description: Perform mathematical calculations + inputSchema: + type: object + properties: + expression: + type: string + description: Mathematical expression to evaluate + required: + - expression + + - name: lookup + description: Look up information in the database + inputSchema: + type: object + properties: + table: + type: string + enum: ["users", "products", "orders"] + id: + type: integer + required: + - table + - id + + - $ref: "./shared/common_tools.json#/definitions/logger" +--- + +# Tool Agent + +An agent demonstrating tool definitions. + +## Instructions + +Use the available tools to help users with calculations and data lookups. diff --git a/test/solid_agent/agent_manifest/manifest_test.rb b/test/solid_agent/agent_manifest/manifest_test.rb new file mode 100644 index 0000000..f7ee6fa --- /dev/null +++ b/test/solid_agent/agent_manifest/manifest_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidAgent + module AgentManifest + class ManifestTest < Minitest::Test + def test_manifest_with_valid_attributes + manifest = Manifest.new( + name: "test-agent", + version: "1.0.0", + description: "A test agent", + model: "anthropic/claude-sonnet-4-20250514" + ) + + assert manifest.valid? + assert_equal "test-agent", manifest.name + assert_equal "1.0.0", manifest.version + assert_equal "anthropic/claude-sonnet-4-20250514", manifest.model + end + + def test_manifest_requires_name + manifest = Manifest.new(version: "1.0.0") + + refute manifest.valid? + assert_includes manifest.errors[:name], "can't be blank" + end + + def test_manifest_validates_name_format + manifest = Manifest.new(name: "Invalid Name") + + refute manifest.valid? + assert manifest.errors[:name].any? { |e| e.include?("lowercase") } + end + + def test_manifest_validates_version_format + manifest = Manifest.new(name: "test", version: "invalid") + + refute manifest.valid? + assert manifest.errors[:version].any? { |e| e.include?("semver") } + end + + def test_manifest_accepts_semver_with_prerelease + manifest = Manifest.new(name: "test", version: "1.0.0-beta.1") + + assert manifest.valid? + end + + def test_manifest_defaults + manifest = Manifest.new(name: "test") + + assert_equal "1.0.0", manifest.version + assert_equal [], manifest.tags + assert_equal [], manifest.tools + assert_equal [], manifest.resources + assert_equal({}, manifest.extensions) + assert_equal({}, manifest.config) + end + + def test_manifest_to_h + manifest = Manifest.new( + name: "test-agent", + description: "Test", + model: "anthropic/claude-sonnet" + ) + + hash = manifest.to_h + + assert_equal "test-agent", hash[:name] + assert_equal "Test", hash[:description] + assert_equal "anthropic/claude-sonnet", hash[:model] + end + + def test_manifest_framework_accessors + manifest = Manifest.new( + name: "test", + extensions: { + activeagent: { class_name: "TestAgent" }, + crewai: { role: "Researcher" } + } + ) + + assert_equal({ class_name: "TestAgent" }, manifest.activeagent_config) + assert_equal({ role: "Researcher" }, manifest.crewai_config) + end + end + end +end diff --git a/test/solid_agent/agent_manifest/parser_registry_test.rb b/test/solid_agent/agent_manifest/parser_registry_test.rb new file mode 100644 index 0000000..254a779 --- /dev/null +++ b/test/solid_agent/agent_manifest/parser_registry_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidAgent + module AgentManifest + class ParserRegistryTest < Minitest::Test + def test_detect_format_agent_md + assert_equal :agent_md, ParserRegistry.detect_format("agent.agent.md") + assert_equal :agent_md, ParserRegistry.detect_format("/path/to/my-agent.agent.md") + end + + def test_detect_format_dotprompt + assert_equal :dotprompt, ParserRegistry.detect_format("translate.prompt") + assert_equal :dotprompt, ParserRegistry.detect_format("/prompts/basic.prompt") + end + + def test_detect_format_crewai + assert_equal :crewai, ParserRegistry.detect_format("agents.yaml") + assert_equal :crewai, ParserRegistry.detect_format("agents.yml") + assert_equal :crewai, ParserRegistry.detect_format("/config/crew_agents.yaml") + end + + def test_detect_format_github_prompt + assert_equal :github_prompt, ParserRegistry.detect_format("component.prompt.md") + assert_equal :github_prompt, ParserRegistry.detect_format("/copilot/generate.prompt.md") + end + + def test_detect_format_unknown + assert_raises(UnknownFormatError) do + ParserRegistry.detect_format("unknown.txt") + end + end + + def test_registered_formats + formats = ParserRegistry.formats + + assert_includes formats, :agent_md + assert_includes formats, :dotprompt + assert_includes formats, :crewai + assert_includes formats, :github_prompt + end + + def test_supports_format + assert ParserRegistry.supports?(:agent_md) + assert ParserRegistry.supports?(:dotprompt) + refute ParserRegistry.supports?(:unknown_format) + end + + def test_parser_for_format + parser = ParserRegistry.parser_for(:agent_md) + assert_equal Parsers::AgentMdParser, parser + end + + def test_parser_for_unknown_format + assert_raises(UnknownFormatError) do + ParserRegistry.parser_for(:unknown) + end + end + end + end +end diff --git a/test/solid_agent/agent_manifest/parsers/agent_md_parser_test.rb b/test/solid_agent/agent_manifest/parsers/agent_md_parser_test.rb new file mode 100644 index 0000000..c606101 --- /dev/null +++ b/test/solid_agent/agent_manifest/parsers/agent_md_parser_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidAgent + module AgentManifest + module Parsers + class AgentMdParserTest < Minitest::Test + def fixture_path(name) + File.expand_path("../../../../fixtures/agent_manifest/valid/#{name}", __FILE__) + end + + def test_parse_minimal + manifest = AgentMdParser.parse(fixture_path("minimal.agent.md")) + + assert_equal "minimal-agent", manifest.name + assert_equal :agent_md, manifest.source_format + end + + def test_parse_full_featured + manifest = AgentMdParser.parse(fixture_path("full_featured.agent.md")) + + assert_equal "research-assistant", manifest.name + assert_equal "2.1.0", manifest.version + assert_equal "ActiveAgents Team", manifest.author + assert_equal "MIT", manifest.license + assert_includes manifest.tags, "research" + assert_equal "anthropic/claude-sonnet-4-20250514", manifest.model + assert_equal 0.7, manifest.config[:temperature] || manifest.config["temperature"] + assert_equal 2, manifest.tools.size + assert_equal 1, manifest.resources.size + end + + def test_parse_with_tools + manifest = AgentMdParser.parse(fixture_path("with_tools.agent.md")) + + assert_equal "tool-agent", manifest.name + assert_equal 3, manifest.tools.size + + calc_tool = manifest.tools.find { |t| t.name == "calculate" } + assert_equal "Perform mathematical calculations", calc_tool.description + assert_equal "object", calc_tool.input_schema["type"] + + ref_tool = manifest.tools.find(&:reference?) + assert ref_tool.ref.include?("common_tools") + end + + def test_parse_string + content = <<~AGENTMD + --- + name: string-parsed + model: openai/gpt-4 + --- + + # Test Agent + + Instructions here. + AGENTMD + + manifest = AgentMdParser.parse_string(content) + + assert_equal "string-parsed", manifest.name + assert_equal "openai/gpt-4", manifest.model + assert_includes manifest.template, "Instructions here" + end + + def test_parse_with_activeagent_extensions + content = <<~AGENTMD + --- + name: extended-agent + activeagent: + class_name: CustomAgent + concerns: + - has_context: + contextual: user + - has_tools + --- + + Test content. + AGENTMD + + manifest = AgentMdParser.parse_string(content) + + assert_equal "CustomAgent", manifest.extensions[:activeagent]["class_name"] + assert_equal 2, manifest.extensions[:activeagent]["concerns"].size + end + + def test_parse_without_frontmatter + content = "# Just Markdown\n\nNo frontmatter here." + + manifest = AgentMdParser.parse_string(content) + + assert_nil manifest.name + assert_includes manifest.template, "No frontmatter here" + end + + def test_parse_malformed_frontmatter_raises + content = <<~BAD + --- + name: test + invalid yaml: [ + --- + + Content + BAD + + assert_raises(ParseError) do + AgentMdParser.parse_string(content) + end + end + end + end + end +end diff --git a/test/solid_agent/agent_manifest/parsers/crewai_parser_test.rb b/test/solid_agent/agent_manifest/parsers/crewai_parser_test.rb new file mode 100644 index 0000000..a1f5ee1 --- /dev/null +++ b/test/solid_agent/agent_manifest/parsers/crewai_parser_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidAgent + module AgentManifest + module Parsers + class CrewAIParserTest < Minitest::Test + def fixture_path(name) + File.expand_path("../../../../fixtures/agent_manifest/crewai/#{name}", __FILE__) + end + + def test_parse_multiple_agents + result = CrewAIParser.parse(fixture_path("agents.yaml")) + + # Should return array for multiple agents + assert_kind_of Array, result + assert_equal 2, result.size + end + + def test_parse_agent_names + result = CrewAIParser.parse(fixture_path("agents.yaml")) + + names = result.map(&:name) + assert_includes names, "research-analyst" + assert_includes names, "reporting-analyst" + end + + def test_parse_agent_model + result = CrewAIParser.parse(fixture_path("agents.yaml")) + + research = result.find { |m| m.name == "research-analyst" } + assert_equal "anthropic/claude-sonnet-4-20250514", research.model + end + + def test_parse_agent_tools + result = CrewAIParser.parse(fixture_path("agents.yaml")) + + research = result.find { |m| m.name == "research-analyst" } + assert_equal 2, research.tools.size + assert research.tools.all?(&:reference?) + end + + def test_parse_crewai_extensions + result = CrewAIParser.parse(fixture_path("agents.yaml")) + + research = result.find { |m| m.name == "research-analyst" } + crewai_ext = research.extensions[:crewai] + + assert_includes crewai_ext[:role], "Senior Research Analyst" + assert_includes crewai_ext[:goal], "cutting-edge developments" + assert crewai_ext[:verbose] + assert_equal false, crewai_ext[:allow_delegation] + end + + def test_parse_string_single_agent + content = <<~YAML + assistant: + role: General Assistant + goal: Help users with tasks + backstory: You are a helpful assistant. + llm: gpt-4o + YAML + + manifest = CrewAIParser.parse_string(content) + + # Single agent returns manifest directly (not array) + assert_kind_of Manifest, manifest + assert_equal "assistant", manifest.name + assert_equal "openai/gpt-4o", manifest.model + end + + def test_builds_instructions_from_role_and_backstory + content = <<~YAML + writer: + role: Technical Writer + goal: Create documentation + backstory: Expert at technical writing. + YAML + + manifest = CrewAIParser.parse_string(content) + + assert_includes manifest.instructions, "Role: Technical Writer" + assert_includes manifest.instructions, "Goal: Create documentation" + assert_includes manifest.instructions, "Expert at technical writing" + end + + def test_normalize_snake_case_to_kebab + content = <<~YAML + data_processor: + role: Data Processor + goal: Process data + YAML + + manifest = CrewAIParser.parse_string(content) + + assert_equal "data-processor", manifest.name + end + end + end + end +end diff --git a/test/solid_agent/agent_manifest/parsers/dotprompt_parser_test.rb b/test/solid_agent/agent_manifest/parsers/dotprompt_parser_test.rb new file mode 100644 index 0000000..e225c03 --- /dev/null +++ b/test/solid_agent/agent_manifest/parsers/dotprompt_parser_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidAgent + module AgentManifest + module Parsers + class DotpromptParserTest < Minitest::Test + def fixture_path(name) + File.expand_path("../../../../fixtures/agent_manifest/dotprompt/#{name}", __FILE__) + end + + def test_parse_basic_prompt + manifest = DotpromptParser.parse(fixture_path("basic.prompt")) + + assert_equal :dotprompt, manifest.source_format + assert_equal "googleai/gemini-1.5-pro", manifest.model + assert_equal 0.7, manifest.config[:temperature] || manifest.config["temperature"] + assert_equal 2048, manifest.config[:max_tokens] || manifest.config["max_tokens"] + end + + def test_parse_input_schema + manifest = DotpromptParser.parse(fixture_path("basic.prompt")) + + assert manifest.input_schema + json_schema = manifest.input_schema.to_json_schema + + assert_equal "string", json_schema["properties"]["text"]["type"] + assert_includes json_schema["required"], "text" + refute_includes json_schema["required"], "language" + end + + def test_parse_output_schema + manifest = DotpromptParser.parse(fixture_path("basic.prompt")) + + assert manifest.output_schema + format = manifest.output_schema["format"] || manifest.output_schema[:format] + assert_equal "json", format + assert manifest.output_schema["schema"] || manifest.output_schema[:schema] + end + + def test_parse_tools + manifest = DotpromptParser.parse(fixture_path("basic.prompt")) + + assert_equal 2, manifest.tools.size + assert manifest.tools.all?(&:reference?) + end + + def test_parse_string + content = <<~DOTPROMPT + --- + model: openai/gpt-4 + temperature: 0.5 + --- + + Summarize: {{ text }} + DOTPROMPT + + manifest = DotpromptParser.parse_string(content) + + assert_equal "openai/gpt-4", manifest.model + assert_equal 0.5, manifest.config[:temperature] || manifest.config["temperature"] + assert_includes manifest.template, "{{ text }}" + end + + def test_model_normalization + content = <<~DOTPROMPT + --- + model: gemini-1.5-pro + --- + + Content + DOTPROMPT + + manifest = DotpromptParser.parse_string(content) + + # Should normalize to include provider + assert manifest.model.include?("/") + end + + def test_generate_name_from_content + content = <<~DOTPROMPT + --- + model: openai/gpt-4 + --- + + # Translation Helper + + Translate the text. + DOTPROMPT + + manifest = DotpromptParser.parse_string(content) + + assert_equal "translation-helper", manifest.name + end + end + end + end +end diff --git a/test/solid_agent/agent_manifest/parsers/github_prompt_parser_test.rb b/test/solid_agent/agent_manifest/parsers/github_prompt_parser_test.rb new file mode 100644 index 0000000..8b530a2 --- /dev/null +++ b/test/solid_agent/agent_manifest/parsers/github_prompt_parser_test.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidAgent + module AgentManifest + module Parsers + class GitHubPromptParserTest < Minitest::Test + def fixture_path(name) + File.expand_path("../../../../fixtures/agent_manifest/github_prompt/#{name}", __FILE__) + end + + def test_parse_github_prompt + manifest = GitHubPromptParser.parse(fixture_path("copilot.prompt.md")) + + assert_equal :github_prompt, manifest.source_format + assert_equal "openai/gpt-4o", manifest.model + end + + def test_parse_tools_as_references + manifest = GitHubPromptParser.parse(fixture_path("copilot.prompt.md")) + + assert_equal 3, manifest.tools.size + assert manifest.tools.all?(&:reference?) + + refs = manifest.tools.map(&:ref) + assert_includes refs, "github://githubRepo" + assert_includes refs, "github://search/codebase" + end + + def test_parse_description + manifest = GitHubPromptParser.parse(fixture_path("copilot.prompt.md")) + + assert_equal "Generate a new React form component with validation", manifest.description + end + + def test_parse_github_extensions + manifest = GitHubPromptParser.parse(fixture_path("copilot.prompt.md")) + + github_ext = manifest.extensions[:github_prompt] + assert_equal "agent", github_ext[:mode] + assert github_ext[:original_tools] + end + + def test_parse_string + content = <<~PROMPT + --- + model: GPT-4 + tools: ['search', 'fetch'] + description: 'Simple prompt' + --- + + Generate code for ${input:feature} + PROMPT + + manifest = GitHubPromptParser.parse_string(content) + + assert_equal "openai/gpt-4", manifest.model + assert_equal 2, manifest.tools.size + assert_equal "Simple prompt", manifest.description + end + + def test_extract_name_from_description + content = <<~PROMPT + --- + description: 'Generate React Components' + --- + + Content here. + PROMPT + + manifest = GitHubPromptParser.parse_string(content) + + assert_equal "generate-react-components", manifest.name + end + + def test_extract_name_from_heading + content = <<~PROMPT + --- + model: GPT-4 + --- + + # API Generator + + Generate API endpoints. + PROMPT + + manifest = GitHubPromptParser.parse_string(content) + + assert_equal "api-generator", manifest.name + end + + def test_normalize_github_model_names + test_cases = [ + ["GPT-4o", "openai/gpt-4o"], + ["gpt4o", "openai/gpt-4o"], + ["GPT-4", "openai/gpt-4"], + ["gpt-3.5-turbo", "openai/gpt-3.5-turbo"], + ["claude-3-opus", "anthropic/claude-3-opus"] + ] + + test_cases.each do |input, expected| + content = <<~PROMPT + --- + model: #{input} + --- + + Test + PROMPT + + manifest = GitHubPromptParser.parse_string(content) + assert_equal expected, manifest.model, "Expected #{input} to normalize to #{expected}" + end + end + end + end + end +end diff --git a/test/solid_agent/agent_manifest/picoschema_test.rb b/test/solid_agent/agent_manifest/picoschema_test.rb new file mode 100644 index 0000000..6ac8b7e --- /dev/null +++ b/test/solid_agent/agent_manifest/picoschema_test.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidAgent + module AgentManifest + class PicoschemaTest < Minitest::Test + def test_simple_string_field + picoschema = { "name" => "string" } + json_schema = Picoschema.to_json_schema(picoschema) + + assert_equal "object", json_schema["type"] + assert_equal "string", json_schema["properties"]["name"]["type"] + assert_includes json_schema["required"], "name" + end + + def test_string_with_description + picoschema = { "query" => "string, The search query" } + json_schema = Picoschema.to_json_schema(picoschema) + + assert_equal "string", json_schema["properties"]["query"]["type"] + assert_equal "The search query", json_schema["properties"]["query"]["description"] + end + + def test_optional_field + picoschema = { "limit?" => "integer" } + json_schema = Picoschema.to_json_schema(picoschema) + + assert_equal "integer", json_schema["properties"]["limit"]["type"] + # Required array might be nil or empty, either way "limit" shouldn't be required + required = json_schema["required"] || [] + refute_includes required, "limit" + end + + def test_enum_type + picoschema = { "status" => "string(pending, active, completed)" } + json_schema = Picoschema.to_json_schema(picoschema) + + assert_equal "string", json_schema["properties"]["status"]["type"] + assert_equal %w[pending active completed], json_schema["properties"]["status"]["enum"] + end + + def test_array_type + picoschema = { "tags" => "[string]" } + json_schema = Picoschema.to_json_schema(picoschema) + + assert_equal "array", json_schema["properties"]["tags"]["type"] + assert_equal "string", json_schema["properties"]["tags"]["items"]["type"] + end + + def test_nested_object + picoschema = { + "user" => { + "name" => "string", + "email?" => "string" + } + } + json_schema = Picoschema.to_json_schema(picoschema) + + assert_equal "object", json_schema["properties"]["user"]["type"] + assert_equal "string", json_schema["properties"]["user"]["properties"]["name"]["type"] + assert_equal "string", json_schema["properties"]["user"]["properties"]["email"]["type"] + assert_includes json_schema["properties"]["user"]["required"], "name" + user_required = json_schema["properties"]["user"]["required"] || [] + refute_includes user_required, "email" + end + + def test_from_json_schema_simple + json_schema = { + "type" => "object", + "properties" => { + "name" => { "type" => "string", "description" => "User name" } + }, + "required" => ["name"] + } + + picoschema = Picoschema.from_json_schema(json_schema) + + assert_equal "string, User name", picoschema["name"] + end + + def test_from_json_schema_optional + json_schema = { + "type" => "object", + "properties" => { + "name" => { "type" => "string" }, + "age" => { "type" => "integer" } + }, + "required" => ["name"] + } + + picoschema = Picoschema.from_json_schema(json_schema) + + assert_equal "string", picoschema["name"] + assert_equal "integer", picoschema["age?"] + end + + def test_from_json_schema_enum + json_schema = { + "type" => "object", + "properties" => { + "status" => { + "type" => "string", + "enum" => %w[draft published] + } + }, + "required" => ["status"] + } + + picoschema = Picoschema.from_json_schema(json_schema) + + assert_equal "string(draft, published)", picoschema["status"] + end + + def test_round_trip_conversion + original = { + "query" => "string, Search query", + "limit?" => "integer", + "format" => "string(web, image, news)" + } + + json_schema = Picoschema.to_json_schema(original) + back = Picoschema.from_json_schema(json_schema) + + # Required fields should have no suffix + assert_equal original["query"], back["query"] + # Optional fields should have ? suffix + assert_equal original["limit?"], back["limit?"] + # Check enum format - using "format" instead of "type" to avoid confusion + assert_equal original["format"], back["format"] + end + end + end +end diff --git a/test/solid_agent/agent_manifest/validator_test.rb b/test/solid_agent/agent_manifest/validator_test.rb new file mode 100644 index 0000000..5b108a1 --- /dev/null +++ b/test/solid_agent/agent_manifest/validator_test.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidAgent + module AgentManifest + class ValidatorTest < Minitest::Test + def test_valid_manifest + manifest = Manifest.new( + name: "test-agent", + model: "anthropic/claude-sonnet", + instructions: "Help users." + ) + + errors = Validator.validate(manifest) + + assert_empty errors + end + + def test_validates_name_required + manifest = Manifest.new(instructions: "Content") + + errors = Validator.validate(manifest) + + assert errors.any? { |e| e.include?("name is required") } + end + + def test_validates_name_format + manifest = Manifest.new( + name: "Invalid Name!", + instructions: "Content" + ) + + errors = Validator.validate(manifest) + + assert errors.any? { |e| e.include?("must start with lowercase") } + end + + def test_validates_reserved_names + manifest = Manifest.new( + name: "agent", + instructions: "Content" + ) + + errors = Validator.validate(manifest) + + assert errors.any? { |e| e.include?("reserved") } + end + + def test_validates_version_semver + manifest = Manifest.new( + name: "test", + version: "invalid", + instructions: "Content" + ) + + errors = Validator.validate(manifest) + + assert errors.any? { |e| e.include?("semantic versioning") } + end + + def test_validates_temperature_range + manifest = Manifest.new( + name: "test", + model: "anthropic/claude-sonnet", + config: { "temperature" => 3.0 }, + instructions: "Content" + ) + + errors = Validator.validate(manifest) + + assert errors.any? { |e| e.include?("temperature") }, "Expected temperature validation error, got: #{errors.inspect}" + end + + def test_validates_tool_names + tool = Tool.new(name: "invalid name", description: "Test") + manifest = Manifest.new( + name: "test", + tools: [tool], + instructions: "Content" + ) + + errors = Validator.validate(manifest) + + assert errors.any? { |e| e.include?("must start with letter") } + end + + def test_validates_duplicate_tool_names + tool1 = Tool.new(name: "search", description: "Search") + tool2 = Tool.new(name: "search", description: "Also search") + manifest = Manifest.new( + name: "test", + tools: [tool1, tool2], + instructions: "Content" + ) + + errors = Validator.validate(manifest) + + assert errors.any? { |e| e.include?("duplicate tool name") } + end + + def test_validates_content_present + manifest = Manifest.new(name: "test") + + errors = Validator.validate(manifest) + + assert errors.any? { |e| e.include?("must have instructions or template") } + end + + def test_valid_returns_boolean + valid_manifest = Manifest.new(name: "test", instructions: "Content") + invalid_manifest = Manifest.new + + assert Validator.valid?(valid_manifest) + refute Validator.valid?(invalid_manifest) + end + + def test_validate_bang_raises_on_invalid + manifest = Manifest.new + + assert_raises(ValidationError) do + Validator.validate!(manifest) + end + end + + def test_validate_bang_returns_manifest_on_valid + manifest = Manifest.new(name: "test", instructions: "Content") + + result = Validator.validate!(manifest) + + assert_equal manifest, result + end + + def test_strict_mode_requires_description + manifest = Manifest.new( + name: "test", + model: "anthropic/claude", + instructions: "Content" + ) + + normal_errors = Validator.validate(manifest) + strict_errors = Validator.validate(manifest, strict: true) + + refute normal_errors.any? { |e| e.include?("description") } + assert strict_errors.any? { |e| e.include?("description is required") } + end + + def test_strict_mode_requires_model + manifest = Manifest.new( + name: "test", + description: "Test agent", + instructions: "Content" + ) + + errors = Validator.validate(manifest, strict: true) + + assert errors.any? { |e| e.include?("model is required") } + end + + def test_validates_repository_url + manifest = Manifest.new( + name: "test", + repository: "not-a-url", + instructions: "Content" + ) + + errors = Validator.validate(manifest) + + assert errors.any? { |e| e.include?("repository must be a valid URL") } + end + + def test_validates_resource_uri + resource = Resource.new(name: "data", uri: "invalid") + manifest = Manifest.new( + name: "test", + resources: [resource], + instructions: "Content" + ) + + errors = Validator.validate(manifest) + + assert errors.any? { |e| e.include?("uri must be a valid URI") } + end + end + end +end diff --git a/test/solid_agent/has_context_test.rb b/test/solid_agent/has_context_test.rb index e8bac4e..670c20c 100644 --- a/test/solid_agent/has_context_test.rb +++ b/test/solid_agent/has_context_test.rb @@ -436,18 +436,18 @@ def test_capture_and_persist_generation_stores_response # === Auto-context tests === - def test_has_context_with_contextable_stores_config - @agent_class.has_context :conversation, contextable: :user + def test_has_context_with_contextual_stores_config + @agent_class.has_context :conversation, contextual: :user config = @agent_class._context_configs[:conversation] - assert_equal :user, config[:contextable] + assert_equal :user, config[:contextual] end - def test_has_context_with_contextable_false_stores_config - @agent_class.has_context :conversation, contextable: false + def test_has_context_with_contextual_false_stores_config + @agent_class.has_context :conversation, contextual: false config = @agent_class._context_configs[:conversation] - assert_equal false, config[:contextable] + assert_equal false, config[:contextual] end def test_has_context_registers_ensure_callback_by_default @@ -456,8 +456,8 @@ def test_has_context_registers_ensure_callback_by_default assert_includes @agent_class.after_prompt_callbacks, :ensure_conversation_exists end - def test_has_context_does_not_register_ensure_callback_when_contextable_false - @agent_class.has_context :conversation, contextable: false + def test_has_context_does_not_register_ensure_callback_when_contextual_false + @agent_class.has_context :conversation, contextual: false refute_includes @agent_class.after_prompt_callbacks || [], :ensure_conversation_exists end @@ -474,8 +474,8 @@ def test_ensure_context_exists_creates_context_automatically assert_equal @agent_class.name, agent.conversation.agent_name end - def test_ensure_context_exists_with_contextable_param - @agent_class.has_context :conversation, contextable: :user + def test_ensure_context_exists_with_contextual_param + @agent_class.has_context :conversation, contextual: :user agent = @agent_class.new mock_user = Object.new @@ -500,7 +500,7 @@ def test_ensure_context_exists_does_not_recreate_existing_context end def test_auto_context_with_named_context_uses_correct_param - @agent_class.has_context :research_session, contextable: :project + @agent_class.has_context :research_session, contextual: :project agent = @agent_class.new mock_project = Object.new @@ -511,8 +511,8 @@ def test_auto_context_with_named_context_uses_correct_param assert_equal mock_project, agent.research_session.contextable end - def test_auto_context_without_contextable_creates_anonymous_context - @agent_class.has_context :conversation # No contextable specified, defaults to nil + def test_auto_context_without_contextual_creates_anonymous_context + @agent_class.has_context :conversation # No contextual specified, defaults to nil agent = @agent_class.new agent.params = {} diff --git a/test/test_helper.rb b/test/test_helper.rb index 9a73b60..a83f853 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -30,18 +30,34 @@ class String def blank? empty? || /\A[[:space:]]*\z/.match?(self) end + + def presence + blank? ? nil : self + end end class Array def blank? empty? end + + def presence + empty? ? nil : self + end end class Hash def blank? empty? end + + def present? + !blank? + end + + def presence + empty? ? nil : self + end end # Mock ActionView for template errors @@ -49,6 +65,103 @@ module ActionView class MissingTemplate < StandardError; end end +# Mock ActiveModel +module ActiveModel + module Model + def self.included(base) + base.extend ClassMethods + base.include Validations + end + + module ClassMethods + def model_name + @model_name ||= OpenStruct.new( + name: name&.split("::")&.last || "Model", + singular: (name&.split("::")&.last || "model").downcase, + plural: (name&.split("::")&.last || "models").downcase + "s" + ) + end + end + + def initialize(attributes = {}) + attributes.each do |key, value| + send("#{key}=", value) if respond_to?("#{key}=") + end + end + + def persisted? + false + end + end + + module Validations + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + def validates(field, options = {}) + @validations ||= {} + @validations[field] = options + end + + def validate(method_name = nil, &block) + @custom_validations ||= [] + @custom_validations << (method_name || block) + end + + def validations + @validations || {} + end + + def custom_validations + @custom_validations || [] + end + end + + def valid? + @errors = ErrorsHash.new + self.class.validations.each do |field, options| + value = send(field) + if options[:presence] && value.blank? + @errors.add(field, "can't be blank") + end + if options[:format] && value.present? + regex = options[:format][:with] + unless value =~ regex + @errors.add(field, options[:format][:message] || "is invalid") + end + end + if options[:allow_blank] && value.blank? + # Skip other validations if blank is allowed and value is blank + end + end + @errors.empty? + end + + def errors + @errors ||= ErrorsHash.new + end + + class ErrorsHash < Hash + def add(field, message) + self[field] ||= [] + self[field] << message + end + + def full_messages + flat_map { |field, messages| messages.map { |m| "#{field} #{m}" } } + end + + def empty? + values.all?(&:empty?) + end + end + end +end + +require "ostruct" + # Mock ActiveSupport::Concern before loading solid_agent module ActiveSupport module Concern From f67c2634c94247b08991779a7fc143a5070b4225 Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Thu, 29 Jan 2026 15:10:20 -0800 Subject: [PATCH 2/2] Add Reasonable::Reasons for AI reasoning/thinking trace support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds support for capturing and tracking extended thinking traces from LLMs like Claude (with extended thinking) and OpenAI o1 models. New components: - SolidAgent::Reasonable - Concern for models to store reasoning data - SolidAgent::Reasonable::Reason - Value object for reasoning traces - SolidAgent::HasReasons - Concern for agents to capture reasoning - Generator for adding reasoning columns to existing models Features: - Capture reasoning content and token counts from LLM responses - Track thinking time and metadata - Support for redacted reasoning (provider-redacted content) - Reasoning chain aggregation for multi-step processes - Statistics and summarization helpers - Database persistence support via HasContext integration Inspired by the AgentFragment pattern in tonsoffun/writebook. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../solid_agent/reasons/reasons_generator.rb | 83 +++++++ .../templates/add_reasoning_columns.rb.erb | 12 + lib/solid_agent.rb | 2 + lib/solid_agent/has_reasons.rb | 230 ++++++++++++++++++ lib/solid_agent/reasonable.rb | 181 ++++++++++++++ lib/solid_agent/reasonable/reason.rb | 205 ++++++++++++++++ test/solid_agent/has_reasons_test.rb | 189 ++++++++++++++ test/solid_agent/reasonable/reason_test.rb | 173 +++++++++++++ 8 files changed, 1075 insertions(+) create mode 100644 lib/generators/solid_agent/reasons/reasons_generator.rb create mode 100644 lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb create mode 100644 lib/solid_agent/has_reasons.rb create mode 100644 lib/solid_agent/reasonable.rb create mode 100644 lib/solid_agent/reasonable/reason.rb create mode 100644 test/solid_agent/has_reasons_test.rb create mode 100644 test/solid_agent/reasonable/reason_test.rb diff --git a/lib/generators/solid_agent/reasons/reasons_generator.rb b/lib/generators/solid_agent/reasons/reasons_generator.rb new file mode 100644 index 0000000..3acd02f --- /dev/null +++ b/lib/generators/solid_agent/reasons/reasons_generator.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails/generators" +require "rails/generators/active_record" + +module SolidAgent + module Generators + class ReasonsGenerator < Rails::Generators::Base + include Rails::Generators::Migration + source_root File.expand_path("templates", __dir__) + + desc "Adds reasoning columns to an existing generation model for extended thinking support" + + argument :model_name, type: :string, default: "AgentGeneration", + desc: "The model to add reasoning columns to" + + class_option :content_column, type: :string, default: "reasoning_content", + desc: "Column name for storing reasoning content" + + class_option :tokens_column, type: :string, default: "reasoning_tokens", + desc: "Column name for storing reasoning token count" + + class_option :metadata_column, type: :string, default: "reasoning_metadata", + desc: "Column name for storing reasoning metadata (JSON)" + + def self.next_migration_number(dirname) + ActiveRecord::Generators::Base.next_migration_number(dirname) + end + + def create_migration + @table_name = model_name.underscore.pluralize + @content_column = options[:content_column] + @tokens_column = options[:tokens_column] + @metadata_column = options[:metadata_column] + + migration_template( + "add_reasoning_columns.rb.erb", + "db/migrate/add_reasoning_to_#{@table_name}.rb" + ) + end + + def add_concern_to_model + model_file = "app/models/#{model_name.underscore}.rb" + + return unless File.exist?(model_file) + + inject_into_class model_file, model_name do + " include SolidAgent::Reasonable\n\n" + end + + say "Added SolidAgent::Reasonable to #{model_name}", :green + end + + def show_post_install_message + say "" + say "Reasoning support added to #{model_name}!", :green + say "" + say "Next steps:", :yellow + say " 1. Run migrations: rails db:migrate" + say " 2. Use reasoning in your agents:" + say "" + say " class MyAgent < ApplicationAgent" + say " include SolidAgent::HasReasons" + say " include SolidAgent::HasContext" + say "" + say " has_reasons auto_capture: true, persist: true" + say "" + say " def analyze" + say " result = prompt(" + say " messages: messages," + say " extended_thinking: true" + say " )" + say "" + say " # Access reasoning" + say " last_reasoning.content" + say " total_reasoning_tokens" + say " end" + say " end" + say "" + end + end + end +end diff --git a/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb b/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb new file mode 100644 index 0000000..73a9959 --- /dev/null +++ b/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddReasoningTo<%= @table_name.camelize %> < ActiveRecord::Migration[7.0] + def change + add_column :<%= @table_name %>, :<%= @content_column %>, :text + add_column :<%= @table_name %>, :<%= @tokens_column %>, :integer, default: 0 + add_column :<%= @table_name %>, :<%= @metadata_column %>, :jsonb, default: {} + + add_index :<%= @table_name %>, :<%= @tokens_column %>, + name: "index_<%= @table_name %>_on_<%= @tokens_column %>" + end +end diff --git a/lib/solid_agent.rb b/lib/solid_agent.rb index c3d0056..8f7ad2c 100644 --- a/lib/solid_agent.rb +++ b/lib/solid_agent.rb @@ -22,6 +22,8 @@ def configure require_relative "solid_agent/has_context" require_relative "solid_agent/has_tools" require_relative "solid_agent/streams_tool_updates" +require_relative "solid_agent/reasonable" +require_relative "solid_agent/has_reasons" require_relative "solid_agent/agent_manifest" # Load Rails integration if Rails is present diff --git a/lib/solid_agent/has_reasons.rb b/lib/solid_agent/has_reasons.rb new file mode 100644 index 0000000..a1d376d --- /dev/null +++ b/lib/solid_agent/has_reasons.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +module SolidAgent + # HasReasons provides reasoning/thinking trace collection for agents. + # + # This concern enables agents to capture and track extended thinking + # from LLMs that support it (Claude's extended thinking, OpenAI o1, etc.). + # + # Reasoning traces are captured separately from the main response content, + # allowing for: + # - Transparent AI decision-making + # - Debugging and analysis of AI behavior + # - Audit trails for compliance + # - Cost tracking (reasoning tokens) + # + # @example Basic usage + # class ResearchAgent < ApplicationAgent + # include SolidAgent::HasReasons + # + # def analyze + # result = prompt( + # messages: research_messages, + # extended_thinking: true # Enable extended thinking + # ) + # + # # Reasoning is automatically captured + # last_reasoning.content #=> "Let me analyze this systematically..." + # total_reasoning_tokens #=> 450 + # end + # end + # + # @example Configuring reasoning capture + # class AnalysisAgent < ApplicationAgent + # include SolidAgent::HasReasons + # + # has_reasons( + # auto_capture: true, # Auto-capture from all generations + # persist: true, # Persist to database + # budget_tokens: 10000 # Default reasoning token budget + # ) + # end + # + # @example Accessing reasoning history + # agent.reasons # All captured reasons + # agent.reasoning_chain # Formatted reasoning chain + # agent.total_reasoning_tokens # Sum of all reasoning tokens + # + module HasReasons + extend ActiveSupport::Concern + + included do + class_attribute :_reasons_config, default: { + auto_capture: true, + persist: false, + budget_tokens: nil, + redact_on_persist: false + } + + # Storage for captured reasons + attr_accessor :_captured_reasons + end + + class_methods do + # Configure reasoning behavior for this agent + # + # @param auto_capture [Boolean] Automatically capture reasoning from responses + # @param persist [Boolean] Persist reasoning to context (requires HasContext) + # @param budget_tokens [Integer, nil] Default reasoning token budget + # @param redact_on_persist [Boolean] Redact reasoning content when persisting + def has_reasons(auto_capture: true, persist: false, budget_tokens: nil, redact_on_persist: false) + self._reasons_config = { + auto_capture: auto_capture, + persist: persist, + budget_tokens: budget_tokens, + redact_on_persist: redact_on_persist + } + end + end + + # Get all captured reasons for this agent instance + # + # @return [Array] + def reasons + @_captured_reasons ||= [] + end + + # Get the last captured reason + # + # @return [Reasonable::Reason, nil] + def last_reasoning + reasons.last + end + + # Get the total reasoning tokens used + # + # @return [Integer] + def total_reasoning_tokens + reasons.sum(&:tokens) + end + + # Check if any reasoning has been captured + # + # @return [Boolean] + def has_reasoning? + reasons.any?(&:extended_thinking?) + end + + # Get a formatted reasoning chain (all reasoning in sequence) + # + # @param separator [String] Separator between reasons + # @return [String] + def reasoning_chain(separator: "\n\n---\n\n") + reasons + .select(&:extended_thinking?) + .reject(&:redacted?) + .map(&:content) + .join(separator) + end + + # Get reasoning statistics + # + # @return [Hash] + def reasoning_stats + { + count: reasons.count, + total_tokens: total_reasoning_tokens, + total_thinking_time_ms: reasons.sum { |r| r.thinking_time_ms || 0 }, + redacted_count: reasons.count(&:redacted?), + models: reasons.map(&:model).compact.uniq + } + end + + # Capture reasoning from an LLM response + # + # @param response [Object] LLM response object + # @return [Reasonable::Reason, nil] The captured reason + def capture_reasoning(response) + reason = Reasonable::Reason.from_response(response) + return nil unless reason&.extended_thinking? + + @_captured_reasons ||= [] + @_captured_reasons << reason + + # Persist if configured and HasContext is available + persist_reasoning(reason) if _reasons_config[:persist] + + reason + end + + # Manually add a reason + # + # @param content [String] Reasoning content + # @param tokens [Integer] Token count + # @param metadata [Hash] Additional metadata + # @return [Reasonable::Reason] + def add_reason(content:, tokens: 0, **metadata) + reason = Reasonable::Reason.new( + content: content, + tokens: tokens, + model: current_model, + **metadata + ) + + @_captured_reasons ||= [] + @_captured_reasons << reason + + persist_reasoning(reason) if _reasons_config[:persist] + + reason + end + + # Clear all captured reasons + def clear_reasons! + @_captured_reasons = [] + end + + # Get default prompt options with reasoning configuration + # + # @return [Hash] + def reasoning_prompt_options + options = {} + + if _reasons_config[:budget_tokens] + options[:reasoning_budget_tokens] = _reasons_config[:budget_tokens] + end + + options[:extended_thinking] = true if _reasons_config[:auto_capture] + + options + end + + private + + def persist_reasoning(reason) + return unless respond_to?(:context) && context.respond_to?(:generations) + + # Find the most recent generation and store reasoning + generation = context.generations.order(created_at: :desc).first + return unless generation + + if generation.respond_to?(:store_reason!) + # If generation includes Reasonable + persisted_reason = if _reasons_config[:redact_on_persist] + Reasonable::Reason.new( + content: "[Redacted]", + tokens: reason.tokens, + model: reason.model, + thinking_time_ms: reason.thinking_time_ms, + redacted: true, + metadata: reason.metadata + ) + else + reason + end + + generation.store_reason!(persisted_reason) + end + rescue StandardError => e + # Log but don't fail if persistence fails + Rails.logger.warn "[SolidAgent::HasReasons] Failed to persist reasoning: #{e.message}" if defined?(Rails) + end + + def current_model + return @model if defined?(@model) + return prompt_options[:model] if respond_to?(:prompt_options) && prompt_options[:model] + + nil + end + end +end diff --git a/lib/solid_agent/reasonable.rb b/lib/solid_agent/reasonable.rb new file mode 100644 index 0000000..60d7ba5 --- /dev/null +++ b/lib/solid_agent/reasonable.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require_relative "reasonable/reason" + +module SolidAgent + # Reasonable provides AI reasoning/thinking trace support for models. + # + # This concern enables models to track and store reasoning traces from + # LLMs that support extended thinking (like Claude's extended thinking + # or OpenAI's o1 reasoning models). + # + # Reasoning traces help with: + # - Transparency: Understanding why an AI made certain decisions + # - Debugging: Identifying issues in AI logic + # - Auditing: Maintaining records of AI decision-making processes + # - Learning: Improving prompts based on reasoning patterns + # + # @example Basic usage in a model + # class AgentGeneration < ApplicationRecord + # include SolidAgent::Reasonable + # end + # + # generation.store_reasoning!(response) + # generation.reasoning_content #=> "Let me think step by step..." + # generation.reasoning_tokens #=> 150 + # generation.has_reasoning? #=> true + # + # @example Configuring reasoning storage + # class MyGeneration < ApplicationRecord + # include SolidAgent::Reasonable + # + # reasonable_config( + # column: :thinking_trace, # Custom column name + # tokens_column: :think_tokens, + # auto_extract: true # Auto-extract from responses + # ) + # end + # + module Reasonable + extend ActiveSupport::Concern + + included do + # Default configuration + class_attribute :_reasonable_config, default: { + column: :reasoning_content, + tokens_column: :reasoning_tokens, + metadata_column: :reasoning_metadata, + auto_extract: false + } + end + + class_methods do + # Configure reasoning storage options + # + # @param column [Symbol] Column to store reasoning content + # @param tokens_column [Symbol] Column to store token count + # @param metadata_column [Symbol] Column to store metadata (JSON) + # @param auto_extract [Boolean] Auto-extract reasoning from responses + def reasonable_config(column: nil, tokens_column: nil, metadata_column: nil, auto_extract: nil) + config = _reasonable_config.dup + config[:column] = column if column + config[:tokens_column] = tokens_column if tokens_column + config[:metadata_column] = metadata_column if metadata_column + config[:auto_extract] = auto_extract unless auto_extract.nil? + self._reasonable_config = config + end + end + + # Store reasoning from an LLM response + # + # @param response [Object] LLM response with reasoning data + # @return [Reason, nil] The extracted reason or nil + def store_reasoning!(response) + reason = Reason.from_response(response) + return nil unless reason&.extended_thinking? + + update_reasoning_columns(reason) + reason + end + + # Store reasoning from a Reason object + # + # @param reason [Reason] The reason to store + # @return [Reason] The stored reason + def store_reason!(reason) + return nil unless reason.is_a?(Reason) + + update_reasoning_columns(reason) + reason + end + + # Get the stored reasoning content + # + # @return [String, nil] + def reasoning_content + column = _reasonable_config[:column] + respond_to?(column) ? send(column) : nil + end + + # Get the stored reasoning token count + # + # @return [Integer] + def reasoning_tokens + column = _reasonable_config[:tokens_column] + (respond_to?(column) ? send(column) : 0).to_i + end + + # Get the stored reasoning metadata + # + # @return [Hash] + def reasoning_metadata + column = _reasonable_config[:metadata_column] + (respond_to?(column) ? send(column) : {}) || {} + end + + # Check if this record has reasoning stored + # + # @return [Boolean] + def has_reasoning? + reasoning_content.present? || reasoning_tokens.positive? + end + + # Check if reasoning was redacted + # + # @return [Boolean] + def reasoning_redacted? + reasoning_metadata["redacted"] == true + end + + # Get a Reason object from stored data + # + # @return [Reason, nil] + def to_reason + return nil unless has_reasoning? + + Reason.new( + content: reasoning_content, + tokens: reasoning_tokens, + model: respond_to?(:model) ? model : nil, + thinking_time_ms: reasoning_metadata["thinking_time_ms"], + redacted: reasoning_redacted?, + metadata: reasoning_metadata, + created_at: respond_to?(:created_at) ? created_at : nil + ) + end + + # Get a summary of the reasoning + # + # @param length [Integer] Maximum length + # @return [String] + def reasoning_summary(length: 200) + return "[Redacted]" if reasoning_redacted? + return "" if reasoning_content.blank? + + reasoning_content.truncate(length) + end + + private + + def update_reasoning_columns(reason) + updates = {} + + content_col = _reasonable_config[:column] + tokens_col = _reasonable_config[:tokens_column] + metadata_col = _reasonable_config[:metadata_column] + + updates[content_col] = reason.content if respond_to?("#{content_col}=") + updates[tokens_col] = reason.tokens if respond_to?("#{tokens_col}=") + + if respond_to?("#{metadata_col}=") + updates[metadata_col] = { + thinking_time_ms: reason.thinking_time_ms, + redacted: reason.redacted, + model: reason.model + }.merge(reason.metadata).compact + end + + update!(updates) if updates.any? && respond_to?(:update!) + end + end +end diff --git a/lib/solid_agent/reasonable/reason.rb b/lib/solid_agent/reasonable/reason.rb new file mode 100644 index 0000000..4b7e007 --- /dev/null +++ b/lib/solid_agent/reasonable/reason.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +module SolidAgent + module Reasonable + # Reason represents a single reasoning trace from an LLM's extended thinking. + # + # LLMs like Claude (with extended thinking) and OpenAI's o1 models produce + # reasoning traces that explain their thought process before generating output. + # This class captures and structures that reasoning for persistence and analysis. + # + # @example Creating a reason from an LLM response + # reason = Reason.new( + # content: "Let me think about this step by step...", + # tokens: 150, + # model: "claude-sonnet-4-20250514", + # thinking_time_ms: 2500 + # ) + # + # @example Checking if reasoning was used + # reason.extended_thinking? #=> true + # reason.summary(100) #=> "Let me think about this..." + # + class Reason + attr_reader :content, :tokens, :model, :thinking_time_ms, + :created_at, :metadata, :redacted + + # Initialize a new Reason + # + # @param content [String] The reasoning content/trace + # @param tokens [Integer] Number of reasoning tokens used + # @param model [String] The model that generated the reasoning + # @param thinking_time_ms [Integer, nil] Time spent on reasoning + # @param redacted [Boolean] Whether content was redacted by provider + # @param metadata [Hash] Additional provider-specific metadata + def initialize(content:, tokens: 0, model: nil, thinking_time_ms: nil, + redacted: false, metadata: {}, created_at: nil) + @content = content + @tokens = tokens.to_i + @model = model + @thinking_time_ms = thinking_time_ms + @redacted = redacted + @metadata = metadata || {} + @created_at = created_at || Time.current + end + + # Check if this represents extended thinking (vs. standard generation) + # + # @return [Boolean] + def extended_thinking? + tokens.positive? || content.present? + end + + # Check if the reasoning content was redacted by the provider + # + # @return [Boolean] + def redacted? + @redacted == true + end + + # Get a summary of the reasoning content + # + # @param length [Integer] Maximum length of summary + # @return [String] + def summary(length: 200) + return "[Redacted]" if redacted? + return "" if content.blank? + + str = content.to_s + return str if str.length <= length + + "#{str[0, length - 3]}..." + end + + # Convert to a hash for serialization + # + # @return [Hash] + def to_h + { + content: content, + tokens: tokens, + model: model, + thinking_time_ms: thinking_time_ms, + redacted: redacted, + metadata: metadata, + created_at: created_at&.iso8601 + }.compact + end + + # Create from a hash (deserialization) + # + # @param hash [Hash] Hash representation + # @return [Reason] + def self.from_h(hash) + return nil unless hash.is_a?(Hash) + + new( + content: hash[:content] || hash["content"], + tokens: hash[:tokens] || hash["tokens"] || 0, + model: hash[:model] || hash["model"], + thinking_time_ms: hash[:thinking_time_ms] || hash["thinking_time_ms"], + redacted: hash[:redacted] || hash["redacted"] || false, + metadata: hash[:metadata] || hash["metadata"] || {}, + created_at: parse_time(hash[:created_at] || hash["created_at"]) + ) + end + + # Create from an ActiveAgent/LLM provider response + # + # @param response [Object] Provider response object + # @return [Reason, nil] + def self.from_response(response) + return nil unless response + + # Handle different response formats + reasoning_content = extract_reasoning_content(response) + reasoning_tokens = extract_reasoning_tokens(response) + + return nil if reasoning_content.blank? && reasoning_tokens.zero? + + new( + content: reasoning_content, + tokens: reasoning_tokens, + model: response.respond_to?(:model) ? response.model : nil, + thinking_time_ms: extract_thinking_time(response), + redacted: reasoning_redacted?(response), + metadata: extract_reasoning_metadata(response) + ) + end + + class << self + private + + def parse_time(value) + return nil unless value + return value if value.is_a?(Time) + + Time.parse(value.to_s) + rescue ArgumentError + nil + end + + def extract_reasoning_content(response) + # Claude format + return response.reasoning_content if response.respond_to?(:reasoning_content) + + # OpenAI o1 format (reasoning is often in a separate field) + if response.respond_to?(:choices) && response.choices&.first + choice = response.choices.first + return choice.reasoning if choice.respond_to?(:reasoning) + end + + # Check usage for reasoning summary + if response.respond_to?(:usage) && response.usage + usage = response.usage + return usage.reasoning_content if usage.respond_to?(:reasoning_content) + end + + nil + end + + def extract_reasoning_tokens(response) + return 0 unless response.respond_to?(:usage) && response.usage + + usage = response.usage + if usage.respond_to?(:reasoning_tokens) + usage.reasoning_tokens || 0 + else + 0 + end + end + + def extract_thinking_time(response) + return nil unless response.respond_to?(:usage) && response.usage + + usage = response.usage + # Some providers track thinking time separately + if usage.respond_to?(:thinking_time_ms) + usage.thinking_time_ms + elsif usage.respond_to?(:reasoning_time_ms) + usage.reasoning_time_ms + end + end + + def reasoning_redacted?(response) + return false unless response.respond_to?(:usage) && response.usage + + usage = response.usage + usage.respond_to?(:reasoning_redacted) && usage.reasoning_redacted == true + end + + def extract_reasoning_metadata(response) + metadata = {} + + if response.respond_to?(:usage) && response.usage + usage = response.usage + metadata[:budget_tokens] = usage.reasoning_budget_tokens if usage.respond_to?(:reasoning_budget_tokens) + metadata[:effort] = usage.reasoning_effort if usage.respond_to?(:reasoning_effort) + end + + metadata.compact + end + end + end + end +end diff --git a/test/solid_agent/has_reasons_test.rb b/test/solid_agent/has_reasons_test.rb new file mode 100644 index 0000000..11d3fc2 --- /dev/null +++ b/test/solid_agent/has_reasons_test.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidAgent + class HasReasonsTest < Minitest::Test + def setup + @agent_class = Class.new(SolidAgentTestHelpers::MockBaseAgent) do + include SolidAgent::HasReasons + end + + @agent = @agent_class.new + end + + def test_reasons_starts_empty + assert_equal [], @agent.reasons + end + + def test_has_reasoning_false_when_empty + refute @agent.has_reasoning? + end + + def test_capture_reasoning_from_response + response = mock_reasoning_response( + content: "Let me analyze this...", + tokens: 150 + ) + + reason = @agent.capture_reasoning(response) + + assert reason + assert_equal "Let me analyze this...", reason.content + assert_equal 150, reason.tokens + assert_equal 1, @agent.reasons.count + end + + def test_capture_reasoning_returns_nil_without_reasoning + response = mock_reasoning_response(content: nil, tokens: 0) + + reason = @agent.capture_reasoning(response) + + assert_nil reason + assert_equal 0, @agent.reasons.count + end + + def test_last_reasoning + @agent.capture_reasoning(mock_reasoning_response(content: "First", tokens: 50)) + @agent.capture_reasoning(mock_reasoning_response(content: "Second", tokens: 75)) + + assert_equal "Second", @agent.last_reasoning.content + end + + def test_total_reasoning_tokens + @agent.capture_reasoning(mock_reasoning_response(content: "A", tokens: 50)) + @agent.capture_reasoning(mock_reasoning_response(content: "B", tokens: 75)) + @agent.capture_reasoning(mock_reasoning_response(content: "C", tokens: 100)) + + assert_equal 225, @agent.total_reasoning_tokens + end + + def test_has_reasoning_true_after_capture + @agent.capture_reasoning(mock_reasoning_response(content: "Thinking", tokens: 50)) + + assert @agent.has_reasoning? + end + + def test_reasoning_chain + @agent.capture_reasoning(mock_reasoning_response(content: "First thought", tokens: 50)) + @agent.capture_reasoning(mock_reasoning_response(content: "Second thought", tokens: 75)) + + chain = @agent.reasoning_chain(separator: " | ") + + assert_equal "First thought | Second thought", chain + end + + def test_reasoning_chain_excludes_redacted + @agent.add_reason(content: "Visible", tokens: 50) + @agent.add_reason(content: "[Redacted]", tokens: 75, redacted: true) + @agent.add_reason(content: "Also visible", tokens: 60) + + chain = @agent.reasoning_chain(separator: " | ") + + assert_equal "Visible | Also visible", chain + end + + def test_add_reason_manually + reason = @agent.add_reason( + content: "Manual reasoning", + tokens: 100, + thinking_time_ms: 500 + ) + + assert_equal "Manual reasoning", reason.content + assert_equal 100, reason.tokens + assert_equal 1, @agent.reasons.count + end + + def test_clear_reasons + @agent.add_reason(content: "A", tokens: 50) + @agent.add_reason(content: "B", tokens: 60) + + assert_equal 2, @agent.reasons.count + + @agent.clear_reasons! + + assert_equal 0, @agent.reasons.count + end + + def test_reasoning_stats + @agent.add_reason(content: "A", tokens: 50, thinking_time_ms: 100) + @agent.add_reason(content: "B", tokens: 75, thinking_time_ms: 200) + @agent.add_reason(content: "[Redacted]", tokens: 25, redacted: true) + + stats = @agent.reasoning_stats + + assert_equal 3, stats[:count] + assert_equal 150, stats[:total_tokens] + assert_equal 300, stats[:total_thinking_time_ms] + assert_equal 1, stats[:redacted_count] + end + + def test_has_reasons_class_config + configured_class = Class.new(SolidAgentTestHelpers::MockBaseAgent) do + include SolidAgent::HasReasons + has_reasons auto_capture: false, budget_tokens: 5000 + end + + config = configured_class._reasons_config + + assert_equal false, config[:auto_capture] + assert_equal 5000, config[:budget_tokens] + end + + def test_reasoning_prompt_options_with_budget + configured_class = Class.new(SolidAgentTestHelpers::MockBaseAgent) do + include SolidAgent::HasReasons + has_reasons budget_tokens: 10000 + end + + agent = configured_class.new + options = agent.reasoning_prompt_options + + assert_equal 10000, options[:reasoning_budget_tokens] + end + + private + + def mock_reasoning_response(content:, tokens:) + MockReasoningResponse.new(content, tokens) + end + + class MockReasoningResponse + attr_reader :reasoning_content, :usage + + def initialize(content, tokens) + @reasoning_content = content + @usage = MockUsage.new(tokens) + end + + def model + "test-model" + end + + class MockUsage + attr_reader :reasoning_tokens + + def initialize(tokens) + @reasoning_tokens = tokens + end + + def thinking_time_ms + nil + end + + def reasoning_redacted + false + end + + def reasoning_budget_tokens + nil + end + + def reasoning_effort + nil + end + end + end + end +end diff --git a/test/solid_agent/reasonable/reason_test.rb b/test/solid_agent/reasonable/reason_test.rb new file mode 100644 index 0000000..4a0bdcd --- /dev/null +++ b/test/solid_agent/reasonable/reason_test.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidAgent + module Reasonable + class ReasonTest < Minitest::Test + def test_initialize_with_attributes + reason = Reason.new( + content: "Let me think about this...", + tokens: 150, + model: "claude-sonnet-4", + thinking_time_ms: 2500 + ) + + assert_equal "Let me think about this...", reason.content + assert_equal 150, reason.tokens + assert_equal "claude-sonnet-4", reason.model + assert_equal 2500, reason.thinking_time_ms + refute reason.redacted? + end + + def test_extended_thinking_with_content + reason = Reason.new(content: "Thinking...") + + assert reason.extended_thinking? + end + + def test_extended_thinking_with_tokens + reason = Reason.new(content: "", tokens: 100) + + assert reason.extended_thinking? + end + + def test_not_extended_thinking_when_empty + reason = Reason.new(content: "", tokens: 0) + + refute reason.extended_thinking? + end + + def test_redacted + reason = Reason.new(content: "[Redacted]", tokens: 100, redacted: true) + + assert reason.redacted? + assert_equal "[Redacted]", reason.summary + end + + def test_summary_truncates + long_content = "A" * 500 + reason = Reason.new(content: long_content) + + summary = reason.summary(length: 100) + + assert summary.length <= 100 + assert summary.end_with?("...") + end + + def test_to_h + reason = Reason.new( + content: "Thinking...", + tokens: 50, + model: "test-model", + thinking_time_ms: 1000, + metadata: { effort: "high" } + ) + + hash = reason.to_h + + assert_equal "Thinking...", hash[:content] + assert_equal 50, hash[:tokens] + assert_equal "test-model", hash[:model] + assert_equal 1000, hash[:thinking_time_ms] + assert_equal "high", hash[:metadata][:effort] + end + + def test_from_h_with_string_keys + hash = { + "content" => "Reasoning content", + "tokens" => 75, + "model" => "claude" + } + + reason = Reason.from_h(hash) + + assert_equal "Reasoning content", reason.content + assert_equal 75, reason.tokens + assert_equal "claude", reason.model + end + + def test_from_h_with_symbol_keys + hash = { + content: "Reasoning content", + tokens: 75, + model: "claude" + } + + reason = Reason.from_h(hash) + + assert_equal "Reasoning content", reason.content + assert_equal 75, reason.tokens + end + + def test_from_h_returns_nil_for_non_hash + assert_nil Reason.from_h(nil) + assert_nil Reason.from_h("not a hash") + assert_nil Reason.from_h([]) + end + + def test_from_response_with_reasoning_content + response = MockReasoningResponse.new( + reasoning_content: "Step by step analysis...", + reasoning_tokens: 200 + ) + + reason = Reason.from_response(response) + + assert reason + assert_equal "Step by step analysis...", reason.content + assert_equal 200, reason.tokens + end + + def test_from_response_returns_nil_without_reasoning + response = MockReasoningResponse.new( + reasoning_content: nil, + reasoning_tokens: 0 + ) + + reason = Reason.from_response(response) + + assert_nil reason + end + + def test_from_response_with_nil + assert_nil Reason.from_response(nil) + end + + # Mock response class for testing + class MockReasoningResponse + attr_reader :reasoning_content, :model, :usage + + def initialize(reasoning_content: nil, reasoning_tokens: 0, model: nil) + @reasoning_content = reasoning_content + @model = model + @usage = MockUsage.new(reasoning_tokens) + end + + class MockUsage + attr_reader :reasoning_tokens + + def initialize(tokens) + @reasoning_tokens = tokens + end + + def thinking_time_ms + nil + end + + def reasoning_redacted + false + end + + def reasoning_budget_tokens + nil + end + + def reasoning_effort + nil + end + end + end + end + end +end