name: LDR Research (reusable) # Reusable workflow that runs the LDR research script on a caller-supplied # query and produces a comment-ready markdown blob (uploaded as an # artifact). Callers download the artifact and post it to their target # (PR comment, issue comment, Reddit, etc.). This workflow does NOT post # anywhere — that's caller-specific. on: workflow_call: inputs: query: description: 'Fully-assembled research prompt. The caller does all prompt engineering.' type: string required: true model: description: 'OpenRouter model slug. Empty falls back to vars.LDR_RESEARCH_MODEL then a hard default.' type: string default: '' provider: description: 'LLM provider.' type: string default: 'openrouter' search-tool: description: 'Search tool.' type: string default: 'serper' strategy: description: 'LDR search strategy.' type: string default: 'langgraph-agent' iterations: description: 'Override the strategy iteration cap. 0 keeps the strategy default.' type: number default: 0 max-query-length: description: 'Backstop truncation on the query string before invoking the script.' type: number default: 12000 max-sources: description: 'Cap sources rendered in the markdown.' type: number default: 10 comment-header: description: 'Top header line of the formatted markdown.' type: string default: '## LDR Research Results' comment-subheader: description: 'Optional second-line subheader.' type: string default: '' comment-footer: description: 'Footer line. Reddit will swap in a bot disclaimer.' type: string default: '_Generated by [Local Deep Research](https://github.com/LearningCircuit/local-deep-research) E2E test_' include-sources-section: description: 'Render a dedicated sources section. Reddit will set false to fit length caps.' type: boolean default: true output-truncate-chars: description: 'Truncate the rendered markdown to this many chars. 0 disables truncation.' type: number default: 0 runner: description: 'Runner label.' type: string default: 'ubuntu-latest' artifact-suffix: description: 'Optional disambiguator appended to the artifact name. Use a unique value per matrix entry when calling this workflow multiple times in one caller run.' type: string default: '' secrets: OPENROUTER_API_KEY: required: true SERPER_API_KEY: required: true outputs: comment-artifact-name: description: 'Name of the artifact containing comment.md and response.json.' value: ${{ jobs.research.outputs.comment-artifact-name }} success: description: "'true' if LDR returned valid non-error JSON." value: ${{ jobs.research.outputs.success }} permissions: {} jobs: research: name: Run LDR research runs-on: ${{ inputs.runner }} permissions: contents: read outputs: comment-artifact-name: ${{ steps.artifact-name.outputs.name }} success: ${{ steps.run-script.outputs.success }} env: QUERY: ${{ inputs.query }} MAX_QUERY_LENGTH: ${{ inputs.max-query-length }} MAX_SOURCES: ${{ inputs.max-sources }} COMMENT_HEADER: ${{ inputs.comment-header }} COMMENT_SUBHEADER: ${{ inputs.comment-subheader }} COMMENT_FOOTER: ${{ inputs.comment-footer }} INCLUDE_SOURCES: ${{ inputs.include-sources-section }} OUTPUT_TRUNCATE_CHARS: ${{ inputs.output-truncate-chars }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 1 - name: Install jq run: sudo apt-get update && sudo apt-get install -y jq - name: Compute artifact name id: artifact-name env: SUFFIX: ${{ inputs.artifact-suffix }} run: | NAME="ldr-research-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" if [ -n "$SUFFIX" ]; then # Sanitize: artifact names allow [A-Za-z0-9._-] only. SAFE_SUFFIX=$(printf '%s' "$SUFFIX" | tr -c 'A-Za-z0-9._-' '_') NAME="${NAME}-${SAFE_SUFFIX}" fi echo "name=$NAME" >> "$GITHUB_OUTPUT" - name: Resolve model id: resolve-model env: INPUT_MODEL: ${{ inputs.model }} REPO_VAR_MODEL: ${{ vars.LDR_RESEARCH_MODEL }} run: | if [ -n "$INPUT_MODEL" ]; then echo "model=$INPUT_MODEL" >> "$GITHUB_OUTPUT" elif [ -n "$REPO_VAR_MODEL" ]; then echo "model=$REPO_VAR_MODEL" >> "$GITHUB_OUTPUT" else echo "model=google/gemini-2.0-flash-001" >> "$GITHUB_OUTPUT" fi - name: Set up PDM uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5 with: python-version: '3.12' cache: true - name: Install LDR run: pdm install - name: Write query to file (with backstop truncation) run: | # Truncate by total byte count, not per-line. Avoids putting the # entire query on the command line where it could exceed ARG_MAX. QUERY_BYTES=$(printf '%s' "$QUERY" | wc -c) if [ "$QUERY_BYTES" -gt "$MAX_QUERY_LENGTH" ]; then printf '%s' "$QUERY" | head -c "$MAX_QUERY_LENGTH" > query.txt printf '\n... (truncated)\n' >> query.txt echo "Query truncated: $QUERY_BYTES -> $MAX_QUERY_LENGTH bytes" else printf '%s' "$QUERY" > query.txt fi echo "Final query size: $(wc -c < query.txt) bytes" - name: Run LDR Research id: run-script env: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} SERPER_API_KEY: ${{ secrets.SERPER_API_KEY }} LDR_PROVIDER: ${{ inputs.provider }} LDR_SEARCH_TOOL: ${{ inputs.search-tool }} LDR_RESEARCH_MODEL: ${{ steps.resolve-model.outputs.model }} LDR_STRATEGY: ${{ inputs.strategy }} ITERATIONS: ${{ inputs.iterations }} run: | set +e if [ "$ITERATIONS" -gt 0 ] 2>/dev/null; then pdm run python scripts/ldr-research.py --iterations "$ITERATIONS" \ < query.txt 2> >(tee stderr.log >&2) > response.json else pdm run python scripts/ldr-research.py \ < query.txt 2> >(tee stderr.log >&2) > response.json fi LDR_EXIT_CODE=$? set -e echo "=== Response (first 2000 chars): ===" head -c 2000 response.json echo "" echo "=== End response ===" # Catches SIGABRT/native crashes where stdout never flushed; JSON check alone can't see this. if [ "$LDR_EXIT_CODE" -ne 0 ]; then LAST_ERR=$(tail -c 500 stderr.log 2>/dev/null || echo "") echo "::error::ldr-research.py exited with code $LDR_EXIT_CODE: $LAST_ERR" echo "success=false" >> "$GITHUB_OUTPUT" exit 1 fi # `jq .` exits 0 on a zero-byte file, so guard explicitly. if [ ! -s response.json ]; then echo "::error::response.json is empty" echo "success=false" >> "$GITHUB_OUTPUT" exit 1 fi # Shape validation: must be a JSON object. if ! jq -e 'type == "object"' response.json > /dev/null 2>&1; then echo "::error::Response is not a JSON object" echo "success=false" >> "$GITHUB_OUTPUT" exit 1 fi ERROR=$(jq -r '.error // empty' response.json) if [ -n "$ERROR" ]; then echo "::error::LDR error: $ERROR" echo "success=false" >> "$GITHUB_OUTPUT" exit 1 fi # Empty .research would still produce a hollow downstream comment. RESEARCH=$(jq -r '.research // empty' response.json) if [ -z "$RESEARCH" ]; then echo "::error::Response missing or empty .research field" echo "success=false" >> "$GITHUB_OUTPUT" exit 1 fi echo "success=true" >> "$GITHUB_OUTPUT" echo "✅ Research completed" - name: Build comment markdown run: | jq -r '.research' response.json > result.md # Optional sources section if [ "$INCLUDE_SOURCES" = "true" ]; then SOURCES=$(jq -r --argjson cap "$MAX_SOURCES" \ '.sources[:$cap][] | "- [\(.title // "Source")](\(.link // .url // ""))"' \ response.json 2>/dev/null || echo "") else SOURCES="" fi { echo "$COMMENT_HEADER" echo "" if [ -n "$COMMENT_SUBHEADER" ]; then echo "$COMMENT_SUBHEADER" echo "" fi cat result.md echo "" if [ -n "$SOURCES" ]; then echo "### 🔍 Search Sources" echo "" echo "$SOURCES" echo "" fi echo "---" echo "$COMMENT_FOOTER" } > comment.md # Optional truncation (Reddit will use this) if [ "$OUTPUT_TRUNCATE_CHARS" -gt 0 ] 2>/dev/null; then CURRENT=$(wc -c < comment.md) if [ "$CURRENT" -gt "$OUTPUT_TRUNCATE_CHARS" ]; then # Truncate at last paragraph break before the limit, with a budget # for the trailing marker. MARKER='…[truncated]' BUDGET=$((OUTPUT_TRUNCATE_CHARS - ${#MARKER} - 1)) head -c "$BUDGET" comment.md > comment.md.tmp # Trim trailing partial line so we end on a clean paragraph awk 'BEGIN{RS=""} {gsub(/[[:space:]]+$/,""); print}' \ comment.md.tmp > comment.md echo "" >> comment.md echo "$MARKER" >> comment.md rm -f comment.md.tmp echo "Truncated comment.md to $OUTPUT_TRUNCATE_CHARS chars." fi fi echo "Final comment.md size: $(wc -c < comment.md) bytes" - name: Upload research artifact if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.artifact-name.outputs.name }} path: | comment.md response.json stderr.log if-no-files-found: ignore retention-days: 7