feat: use gpt-5.5 via Copilot CLI for analysis + synthesis agents (#518)
- Set model: gpt-5.5 in weekly-analysis.agent.md frontmatter - Created weekly-synthesis.agent.md (Copilot CLI agent for Step 1) - Rewrote synthesis step: renders prompt to file, Copilot CLI processes it - Removed GitHub Models API dependency (_call_synthesis_api dead code retained for reference but no longer called from main) - Updated tests for new render-only synthesis behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
Jun 16, 2026 at 20:05 UTC
5c7fa84b9d942a5e0698b4e186d70c9b1962989a
5 files changed
+168
-75
.github/agents/weekly-analysis.agent.md
+2
@@ -1,6 +1,8 @@
1
---
2
name: Weekly Analysis
3
description: "Focused weekly editorial writer for SquadScope. Reads a prepared prompt file and writes exactly one markdown artifact without delegation."
4
+model: gpt-5.5
5
+tools: ["read", "write"]
6
---
7
8
You are **Weekly Analysis** — Farnsworth's focused editorial writing mode for SquadScope.
.github/agents/weekly-synthesis.agent.md
new
+27
@@ -0,0 +1,27 @@
1
+---
2
+name: Weekly Synthesis
3
+description: "Compact industry narrative generator for SquadScope. Synthesizes press and historical context into a brief editorial overview."
4
+model: gpt-5.5
5
+tools: ["read", "write"]
6
+---
7
+
8
+You are **Weekly Synthesis** — a focused summarization mode for SquadScope's weekly pipeline.
9
+
10
+## Mission
11
+
12
+Read the prepared synthesis prompt and produce a compact industry narrative (max 2000 tokens / ~8000 characters) that captures what's happening in the tech sphere this week from press and historical signals.
13
+
14
+## Hard boundaries
15
+
16
+- Do **not** delegate or spawn sub-agents.
17
+- Do **not** emit commentary, progress notes, or tool narration.
18
+- Do **not** include raw article text — synthesize and distill.
19
+- Do **not** exceed 2000 tokens in your output.
20
+
21
+## Working contract
22
+
23
+1. Read the synthesis prompt file which contains press context, historical context, and continuity data.
24
+2. Produce a compact narrative covering: key industry themes, notable press coverage, how this week connects to recent trends.
25
+3. Write the output to the specified file.
26
+4. The output should be editorial prose (not bullet lists) suitable for injecting as context into a larger analysis prompt.
27
+5. Stop when the file is complete. No epilogue.
.github/workflows/crawl-and-publish.yml
+25
-7
@@ -468,20 +468,38 @@ jobs:
468
PRESS_FILE="$IN_PRESS_FILE"
469
DIAGNOSTICS_DIR="$(dirname "$OUTPUT_FILE")/diagnostics"
470
SYNTHESIS_FILE="$DIAGNOSTICS_DIR/synthesis-narrative.md"
471
+ SYNTHESIS_PROMPT="$DIAGNOSTICS_DIR/synthesis-prompt.md"
472
mkdir -p "$DIAGNOSTICS_DIR"
472
- # Step 1: synthesize press/historical context into compact narrative.
473
+ # Step 1: render synthesis prompt, then run via Copilot CLI.
474
# If this fails, we fall back to the single-prompt approach (no synthesis_input).
474
- if python3 scripts/analyze_fallback.py \
475
+ python3 scripts/analyze_fallback.py \
476
--raw-json "$WEEK_FILE" \
477
--output "$OUTPUT_FILE" \
478
--current-datetime "$CURRENT_DATETIME" \
479
--press-context "$PRESS_FILE" \
480
--run-synthesis \
480
- --synthesis-output "$SYNTHESIS_FILE"; then
481
- echo "synthesis_file=$SYNTHESIS_FILE" >> "$GITHUB_OUTPUT"
482
- echo "synthesis_available=true" >> "$GITHUB_OUTPUT"
481
+ --synthesis-output "$SYNTHESIS_PROMPT"
482
+ if command -v copilot >/dev/null 2>&1 && [ -f "$SYNTHESIS_PROMPT" ]; then
483
+ set +e
484
+ copilot \
485
+ --agent weekly-synthesis \
486
+ -p "Read the synthesis prompt at ${SYNTHESIS_PROMPT}. Write the compact industry narrative to ${SYNTHESIS_FILE}. Max 2000 tokens. Do not delegate." \
487
+ -s \
488
+ --no-ask-user \
489
+ --allow-tool=read \
490
+ --allow-tool=write \
491
+ > "$DIAGNOSTICS_DIR/synthesis-copilot.log" 2>&1
492
+ SYNTH_STATUS=$?
493
+ set -e
494
+ if [ "$SYNTH_STATUS" -eq 0 ] && [ -f "$SYNTHESIS_FILE" ] && [ -s "$SYNTHESIS_FILE" ]; then
495
+ echo "synthesis_file=$SYNTHESIS_FILE" >> "$GITHUB_OUTPUT"
496
+ echo "synthesis_available=true" >> "$GITHUB_OUTPUT"
497
+ else
498
+ echo "::warning::Synthesis Copilot CLI failed (exit=$SYNTH_STATUS); falling back to single-prompt approach."
499
+ echo "synthesis_available=false" >> "$GITHUB_OUTPUT"
500
+ fi
501
else
484
- echo "::warning::Synthesis step failed; falling back to single-prompt approach."
502
+ echo "::warning::Synthesis step skipped (copilot not available or prompt missing); falling back to single-prompt."
503
echo "synthesis_available=false" >> "$GITHUB_OUTPUT"
504
fi
505
@@ -672,7 +690,7 @@ jobs:
690
fi
691
fi
692
675
- # Intentionally rely on Copilot CLI's default model so CI follows the platform-supported default.
693
+ # Model is configured in .github/agents/weekly-analysis.agent.md (gpt-5.5)
694
set +e
695
copilot \
696
--agent weekly-analysis \
scripts/analyze_fallback.py
+83
-22
@@ -47,7 +47,6 @@ COMPACTED_PRESS_CONTEXT_CHARS = 14_000
47
COMPACTED_HISTORICAL_CONTEXT_CHARS = 12_000
48
SYNTHESIS_MAX_TOKENS = 2_000
49
SYNTHESIS_PROMPT_TOKEN_BUDGET = 20_000
50
-DEFAULT_SYNTHESIS_MODEL = "openai/gpt-4o"
50
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
51
NON_RETRYABLE_STATUS_CLASSES = {400, 401, 403, 404}
52
MAX_RETRIES = 3
@@ -236,12 +235,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
235
default=None,
236
help="Path to a pre-computed synthesis narrative to inject into the analysis prompt (Step 2).",
237
)
239
- parser.add_argument(
240
- "--synthesis-model",
241
- type=str,
242
- default=None,
243
- help=f"Model to use for synthesis step (default: {DEFAULT_SYNTHESIS_MODEL}).",
244
- )
238
return parser.parse_args(argv)
239
240
@@ -818,6 +811,75 @@ def _build_synthesis_prompt(
811
return "\n\n---\n\n".join(sections)
812
813
814
+def render_synthesis_prompt(
815
+ *,
816
+ press_context_path: Path | None = None,
817
+ content_root: Path = DEFAULT_CONTENT_ROOT,
818
+ continuity_file: Path = DEFAULT_CONTINUITY_FILE,
819
+ current_datetime: str,
820
+ current_week: str,
821
+ previous_summary_path: Path | None = None,
822
+ prompt_token_budget: int = SYNTHESIS_PROMPT_TOKEN_BUDGET,
823
+) -> str:
824
+ """Render the synthesis prompt to a string (for Copilot CLI to process).
825
+
826
+ Returns the prompt string, or empty string if no meaningful content exists.
827
+ """
828
+ historical_context_content = assemble_historical_context(
829
+ current_datetime=current_datetime,
830
+ previous_summary_path=previous_summary_path,
831
+ content_root=content_root,
832
+ max_words=1_500,
833
+ prompt_token_budget=prompt_token_budget,
834
+ ).strip()
835
+ from scripts.sanitize_repo_content import _escape_untrusted_boundaries
836
+
837
+ historical_context_content = _escape_untrusted_boundaries(historical_context_content)
838
+ if not historical_context_content:
839
+ historical_context_content = "_No historical context was available beyond the current weekly payload._"
840
+
841
+ continuity_content = render_continuity(continuity_file)
842
+
843
+ press_content = (
844
+ press_context_path.read_text(encoding="utf-8").strip()
845
+ if press_context_path and press_context_path.exists() and press_context_path.stat().st_size > 0
846
+ else ""
847
+ )
848
+
849
+ if press_content:
850
+ press_content = _strip_ai_instruction_blocks(press_content)
851
+ if press_content:
852
+ press_content = _escape_untrusted_boundaries(press_content)
853
+
854
+ # If there's no meaningful content to synthesize, return empty
855
+ if not press_content and historical_context_content.startswith("_No historical context"):
856
+ return ""
857
+
858
+ prompt = _build_synthesis_prompt(
859
+ press_content=press_content,
860
+ historical_context_content=historical_context_content,
861
+ continuity_content=continuity_content,
862
+ current_week=current_week,
863
+ current_datetime=current_datetime,
864
+ )
865
+
866
+ # Truncate press content if prompt exceeds budget
867
+ prompt_tokens = estimate_tokens(prompt)
868
+ if prompt_tokens > prompt_token_budget:
869
+ excess_chars = (prompt_tokens - prompt_token_budget) * 4
870
+ end_index = max(0, len(press_content) - excess_chars)
871
+ press_content = press_content[:end_index]
872
+ prompt = _build_synthesis_prompt(
873
+ press_content=press_content,
874
+ historical_context_content=historical_context_content,
875
+ continuity_content=continuity_content,
876
+ current_week=current_week,
877
+ current_datetime=current_datetime,
878
+ )
879
+
880
+ return prompt
881
+
882
+
883
def run_synthesis_step(
884
*,
885
press_context_path: Path | None = None,
@@ -1757,25 +1819,24 @@ def main(argv: list[str] | None = None) -> int:
1819
sanitized_payload = sanitize_repo_payload(payload)
1820
current_week = sanitized_payload["week"]
1821
previous_summary_path = find_previous_summary(current_week, args.analyzed_dir)
1760
- try:
1761
- narrative = run_synthesis_step(
1762
- press_context_path=args.press_context,
1763
- content_root=args.content_root,
1764
- continuity_file=continuity_file,
1765
- current_datetime=args.current_datetime,
1766
- current_week=current_week,
1767
- previous_summary_path=previous_summary_path,
1768
- prompt_token_budget=args.prompt_token_budget,
1769
- model=args.synthesis_model,
1770
- )
1771
- except RuntimeError as exc:
1772
- print(f"::warning::Synthesis step failed: {exc}", file=sys.stderr)
1822
+ # Render synthesis prompt to file (Copilot CLI will process it)
1823
+ narrative_or_prompt = render_synthesis_prompt(
1824
+ press_context_path=args.press_context,
1825
+ content_root=args.content_root,
1826
+ continuity_file=continuity_file,
1827
+ current_datetime=args.current_datetime,
1828
+ current_week=current_week,
1829
+ previous_summary_path=previous_summary_path,
1830
+ prompt_token_budget=args.prompt_token_budget,
1831
+ )
1832
+ if not narrative_or_prompt:
1833
+ print("::warning::No meaningful content for synthesis (no press or historical context).", file=sys.stderr)
1834
return 1
1835
output_path = args.synthesis_output or args.output
1836
output_path.parent.mkdir(parents=True, exist_ok=True)
1776
- output_path.write_text(narrative, encoding="utf-8")
1837
+ output_path.write_text(narrative_or_prompt, encoding="utf-8")
1838
print(
1778
- f"::notice::Synthesis step complete: {estimate_tokens(narrative)} tokens written to {output_path}",
1839
+ f"::notice::Synthesis prompt rendered: {estimate_tokens(narrative_or_prompt)} tokens written to {output_path}",
1840
file=sys.stderr,
1841
)
1842
return 0
tests/test_analyze_fallback.py
+31
-46
@@ -635,13 +635,13 @@ class AnalyzeFallbackTests(unittest.TestCase):
635
markdown = analyze_fallback.call_github_models("prompt")
636
self.assertEqual(markdown, "# Summary\n")
637
638
- def test_run_synthesis_exits_zero_and_writes_output(self) -> None:
639
- """--run-synthesis should call synthesis API and write output file."""
638
+ def test_run_synthesis_exits_zero_and_writes_prompt(self) -> None:
639
+ """--run-synthesis should render synthesis prompt to output file."""
640
tests_root = Path(__file__).resolve().parent
641
with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
642
base = Path(tmpdir)
643
raw_path = base / "data" / "raw" / "2026-W21.json"
644
- output_path = base / "synthesis-output.md"
644
+ output_path = base / "synthesis-prompt.md"
645
press_path = base / "press.md"
646
raw_path.parent.mkdir(parents=True)
647
raw_path.write_text(
@@ -650,63 +650,48 @@ class AnalyzeFallbackTests(unittest.TestCase):
650
)
651
press_path.write_text("Some press context about AI.", encoding="utf-8")
652
653
- fake_response = _FakeHTTPResponse(
654
- json.dumps({"choices": [{"message": {"content": "Synthesized narrative."}}]}).encode()
653
+ exit_code = analyze_fallback.main(
654
+ [
655
+ "--raw-json", str(raw_path),
656
+ "--output", str(base / "unused.md"),
657
+ "--current-datetime", "2026-05-18T13:05:53.678+02:00",
658
+ "--press-context", str(press_path),
659
+ "--run-synthesis",
660
+ "--synthesis-output", str(output_path),
661
+ ]
662
)
656
- fake_response.status = 200
657
-
658
- with mock.patch.dict(
659
- "os.environ",
660
- {"GITHUB_TOKEN": "token", "GITHUB_MODELS_ENDPOINT": analyze_fallback.DEFAULT_MODELS_ENDPOINT},
661
- clear=False,
662
- ), mock.patch.object(analyze_fallback.request, "urlopen", return_value=fake_response):
663
- exit_code = analyze_fallback.main(
664
- [
665
- "--raw-json", str(raw_path),
666
- "--output", str(base / "unused.md"),
667
- "--current-datetime", "2026-05-18T13:05:53.678+02:00",
668
- "--press-context", str(press_path),
669
- "--run-synthesis",
670
- "--synthesis-output", str(output_path),
671
- ]
672
- )
663
664
self.assertEqual(exit_code, 0)
665
self.assertTrue(output_path.exists())
676
- self.assertIn("Synthesized narrative", output_path.read_text(encoding="utf-8"))
666
+ content = output_path.read_text(encoding="utf-8")
667
+ self.assertIn("press context", content.lower())
668
+ self.assertIn("2026-W21", content)
669
678
- def test_run_synthesis_returns_one_on_api_failure(self) -> None:
679
- """--run-synthesis should return exit code 1 when API fails."""
670
+ def test_run_synthesis_returns_one_when_no_content(self) -> None:
671
+ """--run-synthesis should return exit code 1 when no meaningful content exists."""
672
tests_root = Path(__file__).resolve().parent
673
with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
674
base = Path(tmpdir)
675
raw_path = base / "data" / "raw" / "2026-W21.json"
684
- press_path = base / "press.md"
676
raw_path.parent.mkdir(parents=True)
677
raw_path.write_text(
678
json.dumps({"week": "2026-W21", "new_repos": [], "trending_repos": []}),
679
encoding="utf-8",
680
)
690
- press_path.write_text("Press content.", encoding="utf-8")
691
-
692
- with mock.patch.dict(
693
- "os.environ",
694
- {"GITHUB_TOKEN": "token", "GITHUB_MODELS_ENDPOINT": analyze_fallback.DEFAULT_MODELS_ENDPOINT},
695
- clear=False,
696
- ), mock.patch.object(
697
- analyze_fallback.request, "urlopen",
698
- side_effect=error.URLError("Connection refused"),
699
- ):
700
- exit_code = analyze_fallback.main(
701
- [
702
- "--raw-json", str(raw_path),
703
- "--output", str(base / "unused.md"),
704
- "--current-datetime", "2026-05-18T13:05:53.678+02:00",
705
- "--press-context", str(press_path),
706
- "--run-synthesis",
707
- "--synthesis-output", str(base / "out.md"),
708
- ]
709
- )
681
+ # Use an empty content root so no historical context is found
682
+ empty_content_root = base / "empty_content"
683
+ empty_content_root.mkdir()
684
+
685
+ exit_code = analyze_fallback.main(
686
+ [
687
+ "--raw-json", str(raw_path),
688
+ "--output", str(base / "unused.md"),
689
+ "--current-datetime", "2026-05-18T13:05:53.678+02:00",
690
+ "--content-root", str(empty_content_root),
691
+ "--run-synthesis",
692
+ "--synthesis-output", str(base / "out.md"),
693
+ ]
694
+ )
695
696
self.assertEqual(exit_code, 1)
697